diff --git a/README.md b/README.md index 234ff4b5..baa7da2a 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,61 @@ # cobalt -Best way to save what you love. +Best way to save what you love. +Main instance: [co.wukko.me](https://co.wukko.me/) -Live: [co.wukko.me](https://co.wukko.me/) - - + [](https://crowdin.com/project/cobalt) [](https://deepsource.io/gh/wukko/cobalt/?ref=repository-badge) [](https://deepsource.io/gh/wukko/cobalt/?ref=repository-badge) ## What's cobalt? cobalt is a social and media platform downloader that doesn't piss you off. -It's fast, friendly, and doesn't have any bullshit that modern web is filled with: no ads, trackers, or analytics. Paste the link, get the video, move on. It's that simple. Just how it should be. +It's fast, friendly, and doesn't have any bullshit that modern web is filled with: no ads, trackers, or analytics. +Paste the link, get the video, move on. It's that simple. Just how it should be. ## Supported services -| Service | Video + Audio | Only audio | Additional features | -| -------- | :---: | :---: | :----- | -| Twitter | ✅ | ✅ | Ability to save multiple videos/GIFs from a single tweet. | -| Twitter Spaces | ❌️ | ✅ | Audio metadata. | -| YouTube & Shorts | ✅ | ✅ | Support for 8K, 4K, HDR, and high FPS videos. Audio metadata & dubs. h264/av1/vp9 codecs. | -| YouTube Music | ❌ | ✅ | Audio metadata. | -| Reddit | ✅ | ✅ | GIFs and videos. | -| TikTok | ✅ | ✅ | Video downloads with or without watermark; image slideshow downloads without watermark. Full audio downloads. | -| Twitch | ✅ | ✅ | | -| SoundCloud | ❌ | ✅ | Audio metadata, downloads from private links. | -| bilibili.com | ✅ | ✅ | | -| Tumblr | ✅ | ✅ | | -| Vimeo | ✅ | ❌️ | | -| VK Videos & Clips | ✅ | ❌️ | | +| Service | Video + Audio | Only audio | Only video | Additional notes or features | +| -------- | :---: | :---: | :---: | :----- | +| bilibili.com | ✅ | ✅ | ✅ | | +| Instagram | ✅ | ✅ | ✅ | Ability to pick what to save from multi-media posts. | +| Instagram Reels | ✅ | ✅ | ✅ | | +| Reddit | ✅ | ✅ | ✅ | Support for GIFs and videos. | +| SoundCloud | ➖ | ✅ | ➖ | Audio metadata, downloads from private links. | +| TikTok | ✅ | ✅ | ✅ | Supports downloads of: videos with or without watermark, images from slideshow without watermark, full (original) audios. | +| Tumblr | ✅ | ✅ | ✅ | | +| Twitter | ✅ | ✅ | ✅ | Ability to pick what to save from multi-media tweets. | +| Twitter Spaces | ➖ | ✅ | ➖ | Audio metadata with all participants and other info. | +| Twitch | ✅ | ✅ | ✅ | | +| Vimeo | ✅ | ✅ | ✅ | Audio downloads are only available for dash files. | +| Vine Archive | ✅ | ✅ | ✅ | | +| VK Videos | ✅ | ❌ | ❌ | | +| VK Clips | ✅ | ❌ | ❌ | | +| YouTube Videos & Shorts | ✅ | ✅ | ✅ | Support for 8K, 4K, HDR, and high FPS videos. Audio metadata & dubs. h264/av1/vp9 codecs. | +| YouTube Music | ➖ | ✅ | ➖ | Audio metadata. | + +This list is not final and keeps expanding over time, make sure to check it once in a while! ## cobalt API -cobalt has an open API that you can use for free. It's easy and straightforward to use, [check out the docs](https://github.com/wukko/cobalt/blob/current/docs/API.md) and see for yourself. +cobalt has an open API that you can use in your projects for **free**. +It's easy and straightforward to use, [check out the docs](https://github.com/wukko/cobalt/blob/current/docs/API.md) and see for yourself. ## How to contribute translations You can translate cobalt to any language you want on [cobalt's Crowdin](https://crowdin-co.wukko.me/). Feel free to ignore QA errors if you think you know better. If you don't see a language you want to translate cobalt to, open an issue, and I'll add it to Crowdin. ### Translation guidelines: -- All text is **ALWAYS** stylized as **lowercase** unless it's STRESSED LIKE THIS or is an internal value like `{ContactLink}` or `{appName}`. +- Text is **ALWAYS** stylized as **lowercase** unless it's STRESSED LIKE THIS or is an internal value like `{ContactLink}` or `{appName}`. - Example: "`this is a live video, i am yet to learn how to look into future. wait for the stream to finish and try again!`". - Notice how **everything is lowercase**, no matter the punctuation marks? Yes, that's cobalt's style and you have to follow it. -- Avoid formal language. Leave it for big and classy tech companies. Use informal language wherever possible. -- Keep translations lively, friendly, and fun. Translate strings as if the user was your buddy. + *Notice how **everything is lowercase**, no matter the punctuation marks? Yes, that's cobalt's style and you have to follow it.* +- Avoid extremely formal language, leave it for big and classy tech companies. Use informal language wherever possible. - You can (and should) rephrase sentences as long as they keep the same sense and send the same message as original. -- You can add wordplays or puns if it feels natural to do so. - Do **NOT** use offensive or explicit vocabulary. -- Check if there are issues in UI with your localization, and optimize it accordingly. If impossible, open an issue. +- Check if there are issues in UI with your localization and optimize it accordingly. If impossible, open an issue. - Be nice. ## Host an instance yourself -You might find cobalt's source code a bit messy, but I do my best to improve it with every commit. - ### Requirements -- Node.js 17.5 or above +- Node.js 18 or above - git -### npm modules -- cors -- dotenv -- esbuild -- express -- express-rate-limit -- ffmpeg-static -- got -- node-cache -- url-pattern -- xml-js -- youtubei.js - Setup script installs all needed `npm` dependencies, but you have to install `Node.js` and `git` yourself. 1. Clone the repo: `git clone https://github.com/wukko/cobalt` @@ -73,20 +63,27 @@ Setup script installs all needed `npm` dependencies, but you have to install `No 3. Run cobalt via `npm start` 4. Done. +### Ubuntu 22.04+ workaround +`nscd` needs to be installed and running so that the `ffmpeg-static` binary can resolve DNS ([#101](https://github.com/wukko/cobalt/issues/101#issuecomment-1494822258)): + +```bash +sudo apt install nscd +sudo service nscd start +``` + ### Docker -It's also possible to host cobalt via a Docker image, but in that case you'd need to set all environment variables by yourself. -That includes: -| Variable | Example | -| -------- | :--- | -| `selfURL` | `https://co.wukko.me/` | -| `port` | `9000` | -| `streamSalt` | `randomly generated sha512 hash` | -| `cors` | `0` | +It's also possible to run cobalt via Docker, but you **need** to set all environment variables yourself: + +| Variable | Description | Example | +| -------- | :--- | :--- | +| `selfURL` | Instance URL | `http://localhost:9000/` or `https://co.wukko.me/` or etc | +| `port` | Instance port | `9000` | +| `cors` | CORS toggle | `0` | ## Disclaimer -cobalt is my passion project, so update release schedule depends solely on my motivation, free time, and mood. Don't expect any consistency in that. +cobalt is my passion project, so update schedule depends solely on my free time, motivation, and mood. +Don't expect any consistency in that. ## License -cobalt is under [AGPL-3.0](https://github.com/wukko/cobalt/blob/current/LICENSE) license. - +cobalt is under [AGPL-3.0](https://github.com/wukko/cobalt/blob/current/LICENSE) license. [Fluent Emoji](https://github.com/microsoft/fluentui-emoji) used in the project is under [MIT](https://github.com/microsoft/fluentui-emoji/blob/main/LICENSE) license. diff --git a/package.json b/package.json index fd8d37d7..1382b1f5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cobalt", "description": "save what you love", - "version": "5.5", + "version": "5.7", "author": "wukko", "exports": "./src/cobalt.js", "type": "module", @@ -34,6 +34,6 @@ "node-cache": "^5.1.2", "url-pattern": "1.0.3", "xml-js": "^1.6.11", - "youtubei.js": "^5.0.0" + "youtubei.js": "^5.1.0" } } diff --git a/src/cobalt.js b/src/cobalt.js index 118ddfb1..990f5d31 100644 --- a/src/cobalt.js +++ b/src/cobalt.js @@ -23,6 +23,7 @@ import { buildFront } from "./modules/build.js"; import { changelogHistory } from "./modules/pageRender/onDemand.js"; import { sha256 } from "./modules/sub/crypto.js"; import findRendered from "./modules/pageRender/findRendered.js"; +import { celebrationsEmoji } from "./modules/pageRender/elements.js"; if (process.env.selfURL && process.env.port) { const commitHash = shortCommit(); @@ -138,21 +139,29 @@ if (process.env.selfURL && process.env.port) { break; case 'onDemand': if (req.query.blockId) { - let blockId = req.query.blockId.slice(0, 3) + let blockId = req.query.blockId.slice(0, 3); let r, j; switch(blockId) { - case "0": + case "0": // changelog history r = changelogHistory(); j = r ? apiJSON(3, { t: r }) : apiJSON(0, { t: "couldn't render this block" }) break; + case "1": // celebrations emoji + r = celebrationsEmoji(); + j = r ? apiJSON(3, { t: r }) : false + break; default: j = apiJSON(0, { t: "couldn't find a block with this id" }) break; } - res.status(j.status).json(j.body); + if (j.body) { + res.status(j.status).json(j.body) + } else { + res.status(204).end() + } } else { - let j = apiJSON(0, { t: "no block id" }) - res.status(j.status).json(j.body); + let j = apiJSON(0, { t: "no block id" }); + res.status(j.status).json(j.body) } break; default: diff --git a/src/config.json b/src/config.json index 5635c179..2871c3ea 100644 --- a/src/config.json +++ b/src/config.json @@ -27,6 +27,9 @@ "boosty": "https://boosty.to/wukko" } }, + "links": { + "saveToGalleryShortcut": "https://www.icloud.com/shortcuts/6d4fe6e5bade4150b8759ce20720c7a3" + }, "celebrations": { "01-01": "🎄", "02-17": "😺", diff --git a/src/front/cobalt.css b/src/front/cobalt.css index 11d41aa0..9f9c8a9c 100644 --- a/src/front/cobalt.css +++ b/src/front/cobalt.css @@ -7,8 +7,9 @@ --padding-1: 0.75rem; --line-height: 1.65rem; --red: rgb(255, 0, 61); - --color: rgb(107, 67, 139); - --gap: 0.6rem; + --gap: 0.5rem; + --gap-no-icon: 0.6rem; + --rainbow-gradient: linear-gradient(161deg,#ffe454,#ff6964,#fe85e5,#bd26fe,#587ae9,#8ded95); } @media (prefers-color-scheme: dark) { :root { @@ -19,7 +20,7 @@ --accent-unhover: rgb(100, 100, 100); --accent-unhover-2: rgb(110, 110, 110); --background: rgb(0, 0, 0); - --checkmark: url(vectorIcons/checkmark_b.svg); + --glow-transparency: 0.45; } } @media (prefers-color-scheme: light) { @@ -31,7 +32,7 @@ --accent-unhover: rgb(190, 190, 190); --accent-unhover-2: rgb(110, 110, 110); --background: rgb(255, 255, 255); - --checkmark: url(vectorIcons/checkmark.svg); + --glow-transparency: 0.6; } } [data-theme="dark"] { @@ -42,7 +43,7 @@ --accent-unhover: rgb(100, 100, 100); --accent-unhover-2: rgb(110, 110, 110); --background: rgb(0, 0, 0); - --checkmark: url(vectorIcons/checkmark_b.svg); + --glow-transparency: 0.45; } [data-theme="light"] { --accent: rgb(25, 25, 25); @@ -52,7 +53,7 @@ --accent-unhover: rgb(190, 190, 190); --accent-unhover-2: rgb(110, 110, 110); --background: rgb(255, 255, 255); - --checkmark: url(vectorIcons/checkmark.svg); + --glow-transparency: 0.6; } html, body { @@ -87,7 +88,7 @@ a { align-items: center; flex-direction: row; flex-wrap: nowrap; - padding: 0.55rem 1rem 0.55rem 0.7rem; + padding: calc(var(--gap) - 0.1rem) calc(var(--gap)*2 - 0.2rem) calc(var(--gap) - 0.1rem) var(--gap); width: auto; margin-right: var(--padding-1); margin-bottom: var(--padding-1); @@ -227,7 +228,7 @@ button:active, color: var(--accent); } #url-input-area { - background: var(--background); + background: none; padding: 0 1rem; width: 100%; color: var(--accent); @@ -349,7 +350,7 @@ button:active, } .changelog-subtitle { font-size: 1.1rem; - padding-bottom: 0.7rem; + padding-bottom: var(--gap-no-icon); } .changelog-banner { width: 100%; @@ -441,7 +442,7 @@ button:active, color: var(--accent-unhover-2); border-bottom: 0.05rem solid var(--accent-unhover-2); padding-bottom: 0.25rem; - margin-bottom: 1rem; + margin-bottom: calc(var(--gap-no-icon)*1.5); } .category-title { text-align: left; @@ -474,7 +475,7 @@ button:active, margin-top: 0.5rem; } .explanation { - margin-top: 1rem; + margin-top: 0.8rem; width: 100%; font-size: 0.8rem; text-align: left; @@ -485,7 +486,7 @@ button:active, color: var(--accent-unhover-2); } .switch { - padding: 0.7rem; + padding: var(--gap-no-icon); width: 100%; text-align: left; color: var(--accent); @@ -515,6 +516,13 @@ button:active, overflow-x: scroll; scrollbar-width: none; } +.switches .switch { + padding-left: calc(var(--gap-no-icon) + 0.1rem); + padding-right: calc(var(--gap-no-icon) + 0.1rem); +} +#popup-settings .switches .switch { + text-align: center; +} .autowidth { width: auto; } @@ -524,12 +532,12 @@ button:active, .text-to-copy { user-select: text; -webkit-user-select: text; - border: var(--border-15); + background: var(--accent-button-bg); padding: var(--padding-1); overflow: auto; } #close-button { - max-width: 2.8rem; + max-width: 2.6rem; margin-left: var(--padding-1); background: var(--background); border: var(--border-15); @@ -540,7 +548,7 @@ button:active, float: right; position: absolute; right: 0; - height: 2.8rem; + height: 2.6rem; } .popup-tab-content { display: none; @@ -552,7 +560,7 @@ button:active, width: 100%; } .popup-tabs { - margin-top: 0.8rem; + margin-top: 0.9rem; } .emoji { margin-right: 0.4rem; @@ -660,6 +668,19 @@ button:active, display: block; text-align: right; } +#about-donate-footer::before { + content: ""; + position: absolute; + height: 110%; + width: 32%; + background: var(--rainbow-gradient); + z-index: -2; + filter: blur(5px); + opacity: var(--glow-transparency); +} +#about-donate-footer:active::before { + opacity: 0; +} /* adapt the page according to screen size */ @media screen and (min-width: 2300px) { html { @@ -754,9 +775,14 @@ button:active, } } @media screen and (max-width: 320px) { + :root { + --gap: 0.38rem; + --gap-no-icon: 0.38rem; + --line-height: 1.2rem; + } #popup-title { - font-size: 1.3rem; - line-height: 2rem; + font-size: 1.07rem; + line-height: 1.5rem; } .footer-button, #audioMode-false, @@ -770,22 +796,61 @@ button:active, #paste .emoji { margin-right: 0; } - .switch, .checkbox, .category-title, .subtitle, #popup-desc { - font-size: .75rem; + .switch, + .checkbox, + .category-title, + .subtitle, + #popup-desc, + .collapse-title { + font-size: .7rem; + } + .collapse-header { + padding: 0.5rem; + } + #popup-above-title, + #url-input-area { + font-size: 0.6rem; } .explanation { - font-size: .77rem; - margin-top: 0.8rem; + font-size: .6rem; + margin-top: 0.5rem; + line-height: 1rem!important; } #popup-desc { - line-height: 1.4rem; + line-height: 1.2rem; + font-size: .64rem; } .changelog-subtitle, #popup-subtitle { - font-size: 0.9rem!important; + font-size: 0.8rem!important; } .category-title { margin-bottom: 0.8rem; } + .emoji { + height: 18px; + width: 18px; + } + .desc-padding { + padding-bottom: 0.8rem; + } + #logo { + font-size: 0.8rem; + } + .popup, + .popup.scrollable, + .popup.small { + height: 98%; + } + [type=checkbox] { + width: 15px; + height: 15px; + border: 0.12rem solid var(--accent); + } + [type=checkbox]:before { + transform: scaleY(.8)scaleX(.7)rotate(45deg); + left: 3.4px; + top: -2px; + } } @media screen and (max-width: 720px) { #cobalt-main-box #bottom { @@ -795,13 +860,17 @@ button:active, width: 100%; } #footer { - bottom: 4%; + bottom: 4.9%; transform: translate(-50%, 0%); } #footer-buttons { flex-direction: column; align-items: stretch; } + #about-donate-footer::before { + height: 50%; + width: 50%; + } .footer-pair .footer-button { width: 100%!important; } diff --git a/src/front/cobalt.js b/src/front/cobalt.js index 8b8d9732..cd77b2d2 100644 --- a/src/front/cobalt.js +++ b/src/front/cobalt.js @@ -1,13 +1,11 @@ -let ua = navigator.userAgent.toLowerCase(); -let isIOS = ua.match("iphone os"); -let isMobile = ua.match("android") || ua.match("iphone os"); -let version = 26; -let regex = new RegExp(/https:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/); -let notification = `
` +const ua = navigator.userAgent.toLowerCase(); +const isIOS = ua.match("iphone os"); +const isMobile = ua.match("android") || ua.match("iphone os"); +const version = 26; +const regex = new RegExp(/https:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/); +const notification = ``; -let store = {} - -let switchers = { +const switchers = { "theme": ["auto", "light", "dark"], "vCodec": ["h264", "av1", "vp9"], "vQuality": ["1080", "max", "2160", "1440", "720", "480", "360"], @@ -15,11 +13,15 @@ let switchers = { "dubLang": ["original", "auto"], "vimeoDash": ["false", "true"], "audioMode": ["false", "true"] -} -let checkboxes = ["disableTikTokWatermark", "fullTikTokAudio", "muteAudio"]; -let exceptions = { // used for mobile devices +}; +const checkboxes = ["disableTikTokWatermark", "fullTikTokAudio", "muteAudio"]; +const exceptions = { // used for mobile devices "vQuality": "720" -} +}; + +const apiURL = ''; + +let store = {}; function eid(id) { return document.getElementById(id) @@ -333,88 +335,103 @@ async function download(url) { if (url.includes("youtube.com/") || url.includes("/youtu.be/")) req.vCodec = sGet("vCodec").slice(0, 4); if ((url.includes("tiktok.com/") || url.includes("douyin.com/")) && sGet("disableTikTokWatermark") === "true") req.isNoTTWatermark = true; } - await fetch('/api/json', { method: "POST", body: JSON.stringify(req), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }).then(async (r) => { - let j = await r.json(); - if (j.status !== "error" && j.status !== "rate-limit") { - if (j.url || j.picker) { - switch (j.status) { - case "redirect": - changeDownloadButton(2, '>>>'); - setTimeout(() => { changeButton(1); }, 1500); - sGet("downloadPopup") === "true" ? popup('download', 1, j.url) : window.open(j.url, '_blank'); - break; - case "picker": - if (j.audio && j.picker) { - changeDownloadButton(2, '?..') - fetch(`${j.audio}&p=1`).then(async (res) => { - let jp = await res.json(); - if (jp.status === "continue") { - changeDownloadButton(2, '>>>'); - popup('picker', 1, { audio: j.audio, arr: j.picker, type: j.pickerType }); - setTimeout(() => { changeButton(1) }, 2500); - } else { - changeButton(0, jp.text); - } - }).catch((error) => internetError()); - } else if (j.picker) { + + let j = await fetch(`${apiURL}/api/json`, { + method: "POST", + body: JSON.stringify(req), + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } + }).then((r) => { return r.json() }).catch((e) => { return false }); + if (!j) { + internetError(); + return + } + + if (j && j.status !== "error" && j.status !== "rate-limit") { + if (j.text && (!j.url || !j.picker)) { + if (j.status === "success") { + changeButton(2, j.text) + } else changeButton(0, loc.noURLReturned); + } + switch (j.status) { + case "redirect": + changeDownloadButton(2, '>>>'); + setTimeout(() => { changeButton(1); }, 1500); + sGet("downloadPopup") === "true" ? popup('download', 1, j.url) : window.open(j.url, '_blank'); + break; + case "picker": + if (j.audio && j.picker) { + changeDownloadButton(2, '?..') + fetch(`${j.audio}&p=1`).then(async (res) => { + let jp = await res.json(); + if (jp.status === "continue") { changeDownloadButton(2, '>>>'); - popup('picker', 1, { arr: j.picker, type: j.pickerType }); + popup('picker', 1, { audio: j.audio, arr: j.picker, type: j.pickerType }); setTimeout(() => { changeButton(1) }, 2500); } else { - changeButton(0, loc.noURLReturned); + changeButton(0, jp.text); } - break; - case "stream": - changeDownloadButton(2, '?..') - fetch(`${j.url}&p=1`).then(async (res) => { - let jp = await res.json(); - if (jp.status === "continue") { - changeDownloadButton(2, '>>>'); window.location.href = j.url; - setTimeout(() => { changeButton(1) }, 2500); - } else { - changeButton(0, jp.text); - } - }).catch((error) => internetError()); - break; - case "success": - changeButton(2, j.text); - break; - default: - changeButton(0, loc.unknownStatus); - break; + }).catch((error) => internetError()); + } else if (j.picker) { + changeDownloadButton(2, '>>>'); + popup('picker', 1, { arr: j.picker, type: j.pickerType }); + setTimeout(() => { changeButton(1) }, 2500); + } else { + changeButton(0, loc.noURLReturned); } - } else { - if (j.status === "success") { - changeButton(2, j.text) - } else changeButton(0, loc.noURLReturned); - } - } else { - changeButton(0, j.text); + break; + case "stream": + changeDownloadButton(2, '?..') + fetch(`${j.url}&p=1`).then(async (res) => { + let jp = await res.json(); + if (jp.status === "continue") { + changeDownloadButton(2, '>>>'); window.location.href = j.url; + setTimeout(() => { changeButton(1) }, 2500); + } else { + changeButton(0, jp.text); + } + }).catch((error) => internetError()); + break; + case "success": + changeButton(2, j.text); + break; + default: + changeButton(0, loc.unknownStatus); + break; } - }).catch((error) => internetError()); + } else if (j && j.text) { + changeButton(0, j.text); + } +} +async function loadCelebrationsEmoji() { + let bac = eid("about-footer").innerHTML; + try { + let j = await fetch(`${apiURL}/api/onDemand?blockId=1`).then((r) => { if (r.status === 200) { return r.json() } else { return false } }).catch(() => { return false }); + if (j && j.status === "success" && j.text) { + eid("about-footer").innerHTML = eid("about-footer").innerHTML.replace('