From dbb83b9e972563bdd6885a48088847e58d49239b Mon Sep 17 00:00:00 2001 From: wukko Date: Wed, 11 Jun 2025 17:50:28 +0600 Subject: [PATCH 001/138] web/i18n/settings: remove unused strings --- web/i18n/en/settings.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/i18n/en/settings.json b/web/i18n/en/settings.json index 7ab13fa0..db225e7e 100644 --- a/web/i18n/en/settings.json +++ b/web/i18n/en/settings.json @@ -10,9 +10,6 @@ "page.local": "local processing", "page.accessibility": "accessibility", - "section.general": "general", - "section.save": "save", - "theme": "theme", "theme.auto": "auto", "theme.light": "light", From eb90843fc98adf0516a8aa14e57a12f88148ffd8 Mon Sep 17 00:00:00 2001 From: jj Date: Wed, 11 Jun 2025 14:17:32 +0000 Subject: [PATCH 002/138] web/pagenav: use pop() instead of at(-1) --- web/src/components/subnav/PageNav.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/subnav/PageNav.svelte b/web/src/components/subnav/PageNav.svelte index 85e18d70..a4e4c7b2 100644 --- a/web/src/components/subnav/PageNav.svelte +++ b/web/src/components/subnav/PageNav.svelte @@ -17,7 +17,7 @@ let screenWidth: number; - $: currentPageTitle = $page.url.pathname.split("/").at(-1); + $: currentPageTitle = $page.url.pathname.split("/").pop(); $: stringPageTitle = currentPageTitle !== pageName ? ` / ${$t(`${pageName}.page.${currentPageTitle}`)}` From a06baa41c1837b33f00762a51155a7aa8810cc3d Mon Sep 17 00:00:00 2001 From: jj Date: Wed, 11 Jun 2025 14:18:04 +0000 Subject: [PATCH 003/138] web: add uuid() function with fallback if randomUUID is missing --- web/src/lib/storage/memory.ts | 3 ++- web/src/lib/storage/opfs.ts | 3 ++- web/src/lib/task-manager/queue.ts | 11 ++++++----- web/src/lib/util.ts | 17 +++++++++++++++++ 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/web/src/lib/storage/memory.ts b/web/src/lib/storage/memory.ts index 45bebc3b..fc462f0b 100644 --- a/web/src/lib/storage/memory.ts +++ b/web/src/lib/storage/memory.ts @@ -1,4 +1,5 @@ import { AbstractStorage } from "./storage"; +import { uuid } from "$lib/util"; export class MemoryStorage extends AbstractStorage { #chunkSize: number; @@ -48,7 +49,7 @@ export class MemoryStorage extends AbstractStorage { } } - return new File(outputView, crypto.randomUUID()); + return new File(outputView, uuid()); } #expand(size: number) { diff --git a/web/src/lib/storage/opfs.ts b/web/src/lib/storage/opfs.ts index 96a1e1f8..623d0b93 100644 --- a/web/src/lib/storage/opfs.ts +++ b/web/src/lib/storage/opfs.ts @@ -1,4 +1,5 @@ import { AbstractStorage } from "./storage"; +import { uuid } from "$lib/util"; const COBALT_PROCESSING_DIR = "cobalt-processing-data"; @@ -19,7 +20,7 @@ export class OPFSStorage extends AbstractStorage { static async init() { const root = await navigator.storage.getDirectory(); const cobaltDir = await root.getDirectoryHandle(COBALT_PROCESSING_DIR, { create: true }); - const handle = await cobaltDir.getFileHandle(crypto.randomUUID(), { create: true }); + const handle = await cobaltDir.getFileHandle(uuid(), { create: true }); const reader = await handle.createSyncAccessHandle(); return new this(cobaltDir, handle, reader); diff --git a/web/src/lib/task-manager/queue.ts b/web/src/lib/task-manager/queue.ts index bd79269f..15caee14 100644 --- a/web/src/lib/task-manager/queue.ts +++ b/web/src/lib/task-manager/queue.ts @@ -4,6 +4,7 @@ import { ffmpegMetadataArgs } from "$lib/util"; import { createDialog } from "$lib/state/dialogs"; import { addItem } from "$lib/state/task-manager/queue"; import { openQueuePopover } from "$lib/state/queue-visibility"; +import { uuid } from "$lib/util"; import type { CobaltQueueItem } from "$lib/types/queue"; import type { CobaltCurrentTasks } from "$lib/types/task-manager"; @@ -20,12 +21,12 @@ export const getMediaType = (type: string) => { } export const createRemuxPipeline = (file: File) => { - const parentId = crypto.randomUUID(); + const parentId = uuid(); const mediaType = getMediaType(file.type); const pipeline: CobaltPipelineItem[] = [{ worker: "remux", - workerId: crypto.randomUUID(), + workerId: uuid(), parentId, workerArgs: { files: [file], @@ -140,7 +141,7 @@ export const createSavePipeline = ( return showError("pipeline.missing_response_data"); } - const parentId = oldTaskId || crypto.randomUUID(); + const parentId = oldTaskId || uuid(); const pipeline: CobaltPipelineItem[] = []; // reverse is needed for audio (second item) to be downloaded first @@ -149,7 +150,7 @@ export const createSavePipeline = ( for (const tunnel of tunnels) { pipeline.push({ worker: "fetch", - workerId: crypto.randomUUID(), + workerId: uuid(), parentId, workerArgs: { url: tunnel, @@ -182,7 +183,7 @@ export const createSavePipeline = ( pipeline.push({ worker: workerType, - workerId: crypto.randomUUID(), + workerId: uuid(), parentId, dependsOn: pipeline.map(w => w.workerId), workerArgs: { diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index 609f651e..f3f33f6e 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -26,3 +26,20 @@ export const ffmpegMetadataArgs = (metadata: CobaltFileMetadata) => } return []; }); + +const digit = () => '0123456789abcdef'[Math.random() * 16 | 0]; +export const uuid = () => { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + + const digits = Array.from({length: 32}, digit); + digits[12] = '4'; + digits[16] = '89ab'[Math.random() * 4 | 0]; + + return digits + .join('') + .match(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/)! + .slice(1) + .join('-'); +} From 81c8daf852ba5a12370181438514a6e5a8bb7f7c Mon Sep 17 00:00:00 2001 From: jj Date: Wed, 11 Jun 2025 14:23:28 +0000 Subject: [PATCH 004/138] web/storage: robuster er opfs availability check --- web/src/lib/storage/opfs.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/web/src/lib/storage/opfs.ts b/web/src/lib/storage/opfs.ts index 623d0b93..d745cb34 100644 --- a/web/src/lib/storage/opfs.ts +++ b/web/src/lib/storage/opfs.ts @@ -42,16 +42,29 @@ export class OPFSStorage extends AbstractStorage { } static async #computeIsAvailable() { + let tempFile = uuid(), ok = true; + if (typeof navigator === 'undefined') return false; if ('storage' in navigator && 'getDirectory' in navigator.storage) { try { - await navigator.storage.getDirectory(); - return true; + const root = await navigator.storage.getDirectory(); + const handle = await root.getFileHandle(tempFile, { create: true }); + const syncAccess = await handle.createSyncAccessHandle(); + syncAccess.close(); } catch { - return false; + ok = false; } + + try { + const root = await navigator.storage.getDirectory(); + await root.removeEntry(tempFile, { recursive: true }); + } catch { + ok = false; + } + + return ok; } return false; From d0298db11297ebd01f79bafc5073e498655d9eff Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 12 Jun 2025 11:48:29 +0600 Subject: [PATCH 005/138] web/i18n/dialog: remove unused strings --- web/i18n/en/dialog.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/i18n/en/dialog.json b/web/i18n/en/dialog.json index 35a15a8c..a38b4124 100644 --- a/web/i18n/en/dialog.json +++ b/web/i18n/en/dialog.json @@ -15,9 +15,6 @@ "import.body": "importing unknown or corrupted files may unexpectedly alter or break cobalt functionality. only import files that you've personally exported and haven't modified. if you were asked to import this file by someone - don't do it.\n\nwe are not responsible for any harm caused by importing unknown setting files.", - "api.override.title": "processing instance override", - "api.override.body": "{{ value }} is now the processing instance. if you don't trust it, press \"cancel\" and it'll be ignored.\n\nyou can change your choice later in processing settings.", - "safety.custom_instance.body": "custom instances can potentially pose privacy & safety risks.\n\nbad instances can:\n1. redirect you away from cobalt and try to scam you.\n2. log all information about your requests, store it forever, and use it to track you.\n3. serve you malicious files (such as malware).\n4. force you to watch ads, or make you pay for downloading.\n\nafter this point, we can't protect you. please be mindful of what instances to use and always trust your gut. if anything feels off, come back to this page, reset the custom instance, and report it to us on github.", "processing.ongoing": "cobalt is currently processing media in this tab. going away will abort it. are you sure you want to do this?", From ace654ea91e5cd16c82833672dfbe00757e77cb4 Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 12 Jun 2025 11:56:09 +0600 Subject: [PATCH 006/138] web/i18n/dialog: remove even more unused strings --- web/i18n/en/dialog.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/i18n/en/dialog.json b/web/i18n/en/dialog.json index a38b4124..44e92dab 100644 --- a/web/i18n/en/dialog.json +++ b/web/i18n/en/dialog.json @@ -17,9 +17,6 @@ "safety.custom_instance.body": "custom instances can potentially pose privacy & safety risks.\n\nbad instances can:\n1. redirect you away from cobalt and try to scam you.\n2. log all information about your requests, store it forever, and use it to track you.\n3. serve you malicious files (such as malware).\n4. force you to watch ads, or make you pay for downloading.\n\nafter this point, we can't protect you. please be mindful of what instances to use and always trust your gut. if anything feels off, come back to this page, reset the custom instance, and report it to us on github.", - "processing.ongoing": "cobalt is currently processing media in this tab. going away will abort it. are you sure you want to do this?", - "processing.title.ongoing": "processing will be cancelled", - "clear_cache.title": "clear all cache?", "clear_cache.body": "all files from the processing queue will be removed and on-device features will take longer to load. this action is immediate and irreversible." } From 863d39db6f0e68eeb99ed56b871cb908a3b1a908 Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 12 Jun 2025 12:37:38 +0600 Subject: [PATCH 007/138] web/i18n/about: remove an unused string --- web/i18n/en/about.json | 1 - 1 file changed, 1 deletion(-) diff --git a/web/i18n/en/about.json b/web/i18n/en/about.json index 0a794c77..638fd7f1 100644 --- a/web/i18n/en/about.json +++ b/web/i18n/en/about.json @@ -1,6 +1,5 @@ { "page.general": "what's cobalt?", - "page.faq": "FAQ", "page.community": "community & support", From 5ea170a5acdd7ff7d96fa141e7917c7705ca47e4 Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 14 Jun 2025 16:35:35 +0600 Subject: [PATCH 008/138] web: deprecate youtube HLS, enable it only via env variable it's now disabled by default because if we ever need HLS for youtube in the future, it'll be managed by the processing instance, not the web client. will probably be removed completely in next major release. --- web/README.md | 11 ++++---- web/src/lib/api/saving-handler.ts | 3 ++- web/src/lib/env.ts | 8 ++++-- web/src/routes/settings/video/+page.svelte | 29 ++++++++++++---------- 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/web/README.md b/web/README.md index a04ff9a4..8c7c42ac 100644 --- a/web/README.md +++ b/web/README.md @@ -12,11 +12,12 @@ them, you must specify them when building the frontend (or running a vite server `WEB_DEFAULT_API` is **required** to run cobalt frontend. -| name | example | description | -|:---------------------|:----------------------------|:--------------------------------------------------------------------------------------------| -| `WEB_HOST` | `cobalt.tools` | domain on which the frontend will be running. used for meta tags and configuring plausible. | -| `WEB_PLAUSIBLE_HOST` | `plausible.io`* | enables plausible analytics with provided hostname as receiver backend. | -| `WEB_DEFAULT_API` | `https://api.cobalt.tools/` | changes url which is used for api requests by frontend clients. | +| name | example | description | +|:--------------------------------|:----------------------------|:--------------------------------------------------------------------------------------------------------| +| `WEB_HOST` | `cobalt.tools` | domain on which the frontend will be running. used for meta tags and configuring plausible. | +| `WEB_PLAUSIBLE_HOST` | `plausible.io`* | enables plausible analytics with provided hostname as receiver backend. | +| `WEB_DEFAULT_API` | `https://api.cobalt.tools/` | changes url which is used for api requests by frontend clients. | +| `ENABLE_DEPRECATED_YOUTUBE_HLS` | `true` | enables the youtube HLS settings entry; allows sending the related variable to the processing instance. | \* don't use plausible.io as receiver backend unless you paid for their cloud service. use your own domain when hosting community edition of plausible. refer to their [docs](https://plausible.io/docs) when needed. diff --git a/web/src/lib/api/saving-handler.ts b/web/src/lib/api/saving-handler.ts index b2dfbbb4..f18bd4ab 100644 --- a/web/src/lib/api/saving-handler.ts +++ b/web/src/lib/api/saving-handler.ts @@ -1,3 +1,4 @@ +import env from "$lib/env"; import API from "$lib/api/api"; import settings from "$lib/state/settings"; import lazySettingGetter from "$lib/settings/lazy-get"; @@ -60,7 +61,7 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr youtubeVideoCodec: getSetting("save", "youtubeVideoCodec"), videoQuality: getSetting("save", "videoQuality"), - youtubeHLS: getSetting("save", "youtubeHLS"), + youtubeHLS: env.ENABLE_DEPRECATED_YOUTUBE_HLS ? getSetting("save", "youtubeHLS") : undefined, convertGif: getSetting("save", "convertGif"), allowH265: getSetting("save", "allowH265"), diff --git a/web/src/lib/env.ts b/web/src/lib/env.ts index 1ba6a843..960d7ddb 100644 --- a/web/src/lib/env.ts +++ b/web/src/lib/env.ts @@ -9,13 +9,17 @@ const getEnv = (_key: string) => { } } +const getEnvBool = (key: string) => { + return getEnv(key) === "true"; +} + const variables = { HOST: getEnv('HOST'), PLAUSIBLE_HOST: getEnv('PLAUSIBLE_HOST'), PLAUSIBLE_ENABLED: getEnv('HOST') && getEnv('PLAUSIBLE_HOST'), DEFAULT_API: getEnv('DEFAULT_API'), - // temporary variable until webcodecs features are ready for testing - ENABLE_WEBCODECS: !!getEnv('ENABLE_WEBCODECS'), + ENABLE_WEBCODECS: getEnvBool('ENABLE_WEBCODECS'), + ENABLE_DEPRECATED_YOUTUBE_HLS: getEnvBool('ENABLE_DEPRECATED_YOUTUBE_HLS'), } const contacts = { diff --git a/web/src/routes/settings/video/+page.svelte b/web/src/routes/settings/video/+page.svelte index 233f0112..56a31db8 100644 --- a/web/src/routes/settings/video/+page.svelte +++ b/web/src/routes/settings/video/+page.svelte @@ -1,4 +1,5 @@ From 5860c50c593b2b74f5adba7b42a91030c0af6668 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 20 Jun 2025 15:50:30 +0600 Subject: [PATCH 033/138] web/settings/video: add youtube container settings --- web/i18n/en/settings.json | 5 +++- web/src/routes/settings/video/+page.svelte | 28 ++++++++++++++++++---- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/web/i18n/en/settings.json b/web/i18n/en/settings.json index db225e7e..ad852176 100644 --- a/web/i18n/en/settings.json +++ b/web/i18n/en/settings.json @@ -28,9 +28,12 @@ "video.quality.144": "144p", "video.quality.description": "if preferred video quality isn't available, next best is picked instead.", - "video.youtube.codec": "youtube codec and container", + "video.youtube.codec": "preferred youtube video codec", "video.youtube.codec.description": "h264: best compatibility, average quality. max quality is 1080p. \nav1: best quality and efficiency. supports 8k & HDR. \nvp9: same quality as av1, but file is ~2x bigger. supports 4k & HDR.\n\nav1 and vp9 aren't widely supported, you might have to use additional software to play/edit them. cobalt picks next best codec if preferred one isn't available.", + "video.youtube.container": "youtube file container", + "video.youtube.container.description": "when \"auto\" is selected, cobalt will pick the best container automatically depending on selected codec: mp4 for h264; webm for vp9/av1.", + "video.youtube.hls": "youtube hls formats", "video.youtube.hls.title": "prefer hls for video & audio", "video.youtube.hls.description": "only h264 and vp9 codecs are available in this mode. original audio codec is aac, it's re-encoded for compatibility, audio quality may be slightly worse than the non-HLS counterpart.\n\nthis option is experimental, it may go away or change in the future.", diff --git a/web/src/routes/settings/video/+page.svelte b/web/src/routes/settings/video/+page.svelte index 56a31db8..b38461b6 100644 --- a/web/src/routes/settings/video/+page.svelte +++ b/web/src/routes/settings/video/+page.svelte @@ -3,7 +3,7 @@ import settings from "$lib/state/settings"; import { t } from "$lib/i18n/translations"; - import { videoQualityOptions } from "$lib/types/settings"; + import { videoQualityOptions, youtubeVideoContainerOptions } from "$lib/types/settings"; import { youtubeVideoCodecOptions } from "$lib/types/settings"; import SettingsCategory from "$components/settings/SettingsCategory.svelte"; @@ -12,9 +12,9 @@ import SettingsToggle from "$components/buttons/SettingsToggle.svelte"; const codecTitles = { - h264: "h264 (mp4)", - av1: "av1 (webm)", - vp9: "vp9 (webm)", + h264: "h264 + aac", + av1: "av1 + opus", + vp9: "vp9 + opus", } @@ -55,6 +55,26 @@ + + + {#each youtubeVideoContainerOptions as value} + + {value} + + {/each} + + + {#if env.ENABLE_DEPRECATED_YOUTUBE_HLS} Date: Fri, 20 Jun 2025 15:56:11 +0600 Subject: [PATCH 034/138] web/settings/metadata: add subtitles language dropdown --- web/i18n/en/settings.json | 5 +++++ web/src/routes/settings/metadata/+page.svelte | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/web/i18n/en/settings.json b/web/i18n/en/settings.json index ad852176..974563d4 100644 --- a/web/i18n/en/settings.json +++ b/web/i18n/en/settings.json @@ -63,6 +63,11 @@ "audio.youtube.dub.description": "cobalt will use a dubbed audio track for selected language if it's available. if not, original will be used instead.", "youtube.dub.original": "original", + "subtitles": "subtitles", + "subtitles.title": "preferred subtitle language", + "subtitles.description": "cobalt will add subtitles to the downloaded file in the preferred language if they're available. if not, nothing will be added.", + "subtitles.none": "none", + "audio.youtube.better_audio": "youtube audio quality", "audio.youtube.better_audio.title": "prefer better quality", "audio.youtube.better_audio.description": "cobalt will try to pick highest quality audio in audio mode. it may not be available depending on youtube's response, current traffic, and server status. custom instances may not support this option.", diff --git a/web/src/routes/settings/metadata/+page.svelte b/web/src/routes/settings/metadata/+page.svelte index 48a2b433..944ec61c 100644 --- a/web/src/routes/settings/metadata/+page.svelte +++ b/web/src/routes/settings/metadata/+page.svelte @@ -1,6 +1,8 @@ @@ -44,6 +49,21 @@ + + + + Date: Fri, 20 Jun 2025 16:09:19 +0600 Subject: [PATCH 035/138] web/api/saving-handler: add youtubeVideoContainer & subtitleLang --- web/src/lib/api/saving-handler.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/src/lib/api/saving-handler.ts b/web/src/lib/api/saving-handler.ts index f18bd4ab..94c0319e 100644 --- a/web/src/lib/api/saving-handler.ts +++ b/web/src/lib/api/saving-handler.ts @@ -50,21 +50,23 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr alwaysProxy: getSetting("save", "alwaysProxy"), downloadMode: getSetting("save", "downloadMode"), + subtitleLang: getSetting("save", "subtitleLang"), filenameStyle: getSetting("save", "filenameStyle"), disableMetadata: getSetting("save", "disableMetadata"), - audioBitrate: getSetting("save", "audioBitrate"), audioFormat: getSetting("save", "audioFormat"), + audioBitrate: getSetting("save", "audioBitrate"), tiktokFullAudio: getSetting("save", "tiktokFullAudio"), youtubeDubLang: getSetting("save", "youtubeDubLang"), youtubeBetterAudio: getSetting("save", "youtubeBetterAudio"), - youtubeVideoCodec: getSetting("save", "youtubeVideoCodec"), videoQuality: getSetting("save", "videoQuality"), + youtubeVideoCodec: getSetting("save", "youtubeVideoCodec"), + youtubeVideoContainer: getSetting("save", "youtubeVideoContainer"), youtubeHLS: env.ENABLE_DEPRECATED_YOUTUBE_HLS ? getSetting("save", "youtubeHLS") : undefined, - convertGif: getSetting("save", "convertGif"), allowH265: getSetting("save", "allowH265"), + convertGif: getSetting("save", "convertGif"), } const response = await API.request(selectedRequest); From 993a885a3ee78fcce265b43a27d8ff6b24609505 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 20 Jun 2025 16:20:32 +0600 Subject: [PATCH 036/138] web/util: add support for subtitle track language metadata --- web/src/lib/types/api.ts | 3 ++- web/src/lib/util.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/web/src/lib/types/api.ts b/web/src/lib/types/api.ts index 13d2b8b7..c34acc16 100644 --- a/web/src/lib/types/api.ts +++ b/web/src/lib/types/api.ts @@ -52,7 +52,8 @@ export const CobaltFileMetadataKeys = [ 'artist', 'album_artist', 'track', - 'date' + 'date', + 'sublanguage', ]; export type CobaltFileMetadata = Record< diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index f3f33f6e..4e833166 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -18,6 +18,13 @@ export const formatFileSize = (size: number | undefined) => { export const ffmpegMetadataArgs = (metadata: CobaltFileMetadata) => Object.entries(metadata).flatMap(([name, value]) => { if (CobaltFileMetadataKeys.includes(name) && typeof value === "string") { + if (name === "sublanguage") { + return [ + '-metadata:s:s:0', + // eslint-disable-next-line no-control-regex + `language=${value.replace(/[\u0000-\u0009]/g, "")}` + ] + } return [ '-metadata', // eslint-disable-next-line no-control-regex From 7ce9d6882bca9091542654fb0aa7bea4b019da66 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 20 Jun 2025 17:27:49 +0600 Subject: [PATCH 037/138] api/youtube: don't use session if user wants subtitles cuz they're not currently available anywhere but HLS --- api/src/processing/services/youtube.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js index b26f7358..f05e3086 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -175,14 +175,13 @@ export default async function (o) { useHLS = false; } - // we can get subtitles reliably only from the iOS client - if (useHLS || o.subtitleLang) { + if (useHLS) { innertubeClient = "IOS"; } // iOS client doesn't have adaptive formats of resolution >1080p, // so we use the WEB_EMBEDDED client instead for those cases - const useSession = + let useSession = env.ytSessionServer && ( ( !useHLS @@ -194,6 +193,12 @@ export default async function (o) { ) ); + // we can get subtitles reliably only from the iOS client + if (o.subtitleLang) { + innertubeClient = "IOS"; + useSession = false; + } + if (useSession) { innertubeClient = env.ytSessionInnertubeClient || "WEB_EMBEDDED"; } From 96a02d554f32bb0092df771e62da88d734f0c904 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 20 Jun 2025 17:28:06 +0600 Subject: [PATCH 038/138] web/package: update libav packages --- pnpm-lock.yaml | 20 ++++++++++---------- web/package.json | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f89125f..6d67cc68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,11 +101,11 @@ importers: specifier: ^5.0.2 version: 5.0.2 '@imput/libav.js-encode-cli': - specifier: 6.7.7 - version: 6.7.7 + specifier: 6.8.7 + version: 6.8.7 '@imput/libav.js-remux-cli': - specifier: ^6.5.7 - version: 6.5.7 + specifier: ^6.8.7 + version: 6.8.7 '@imput/version-info': specifier: workspace:^ version: link:../packages/version-info @@ -554,11 +554,11 @@ packages: resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==} engines: {node: '>=18.18'} - '@imput/libav.js-encode-cli@6.7.7': - resolution: {integrity: sha512-sy0g+IvVHo6pdbfdpAEN8i+LLw2fz5EE+PeX5FZiAOxrA5svmALZtaWtDavTbQ69Yl9vTQB2jZCR2x/NyZndmQ==} + '@imput/libav.js-encode-cli@6.8.7': + resolution: {integrity: sha512-kWZmCwDYOQVSFu1ARsFfd5P0HqEx5TlhDMZFM/o8cWvMv7okCZWzKRMlEvw3EEGkxWkXUsgcf6F65wQEOE/08A==} - '@imput/libav.js-remux-cli@6.5.7': - resolution: {integrity: sha512-41ld+R5aEwllKdbiszOoCf+K614/8bnuheTZnqjgZESwDtX07xu9O8GO0m/Cpsm7O2CyqPZa1qzDx6BZVO15DQ==} + '@imput/libav.js-remux-cli@6.8.7': + resolution: {integrity: sha512-EXyRSaIGDSLs98dFxPsRPWOr0G/cNWPKe94u0Ch4/ZwopDVfi7Z0utekluhowUns09LJ5RN9BuCZwc6slMcaLg==} '@imput/psl@2.0.4': resolution: {integrity: sha512-vuy76JX78/DnJegLuJoLpMmw11JTA/9HvlIADg/f8dDVXyxbh0jnObL0q13h+WvlBO4Gk26Pu8sUa7/h0JGQig==} @@ -2415,9 +2415,9 @@ snapshots: '@humanwhocodes/retry@0.4.1': {} - '@imput/libav.js-encode-cli@6.7.7': {} + '@imput/libav.js-encode-cli@6.8.7': {} - '@imput/libav.js-remux-cli@6.5.7': {} + '@imput/libav.js-remux-cli@6.8.7': {} '@imput/psl@2.0.4': dependencies: diff --git a/web/package.json b/web/package.json index dbd1612a..e7dbb3e9 100644 --- a/web/package.json +++ b/web/package.json @@ -27,8 +27,8 @@ "@eslint/js": "^9.5.0", "@fontsource/ibm-plex-mono": "^5.0.13", "@fontsource/redaction-10": "^5.0.2", - "@imput/libav.js-encode-cli": "6.7.7", - "@imput/libav.js-remux-cli": "^6.5.7", + "@imput/libav.js-encode-cli": "6.8.7", + "@imput/libav.js-remux-cli": "^6.8.7", "@imput/version-info": "workspace:^", "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.20.7", From 0b0f0d65ef08c9e8831fa3d36be48238691b9072 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 20 Jun 2025 17:32:53 +0600 Subject: [PATCH 039/138] web/queue: add subtitle codec args --- web/src/lib/task-manager/queue.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/src/lib/task-manager/queue.ts b/web/src/lib/task-manager/queue.ts index 15caee14..a803b3a0 100644 --- a/web/src/lib/task-manager/queue.ts +++ b/web/src/lib/task-manager/queue.ts @@ -68,6 +68,13 @@ const makeRemuxArgs = (info: CobaltLocalProcessingResponse) => { if (["merge", "remux"].includes(info.type)) { ffargs.push("-c:a", "copy"); + + if (info.tunnel.length === 3) { + ffargs.push( + "-c:s", + info.output.filename.endsWith(".mp4") ? "mov_text" : "webvtt" + ); + } } else if (info.type === "mute") { ffargs.push("-an"); } From 337edfc98412a4a1fe676b9ea02e6031e0dfc1cc Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 20 Jun 2025 17:38:49 +0600 Subject: [PATCH 040/138] api/request/local-processing: return subtitles boolean --- api/src/processing/request.js | 1 + 1 file changed, 1 insertion(+) diff --git a/api/src/processing/request.js b/api/src/processing/request.js index 697e67fc..61f801e0 100644 --- a/api/src/processing/request.js +++ b/api/src/processing/request.js @@ -60,6 +60,7 @@ export function createResponse(responseType, responseData) { type: mime.getType(responseData?.filename) || undefined, filename: responseData?.filename, metadata: responseData?.fileMetadata || undefined, + subtitles: !!responseData?.subtitles || undefined, }, audio: { From 254ad961d3ae13d21d8870d4dcf391ee9756c653 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 20 Jun 2025 17:41:20 +0600 Subject: [PATCH 041/138] web/queue: add subtitle args when output has subtitles not when there are 3 tunnels, that was dumb of me, my bad --- web/src/lib/task-manager/queue.ts | 2 +- web/src/lib/types/api.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/lib/task-manager/queue.ts b/web/src/lib/task-manager/queue.ts index a803b3a0..b47fbe60 100644 --- a/web/src/lib/task-manager/queue.ts +++ b/web/src/lib/task-manager/queue.ts @@ -69,7 +69,7 @@ const makeRemuxArgs = (info: CobaltLocalProcessingResponse) => { if (["merge", "remux"].includes(info.type)) { ffargs.push("-c:a", "copy"); - if (info.tunnel.length === 3) { + if (info.output.subtitles) { ffargs.push( "-c:s", info.output.filename.endsWith(".mp4") ? "mov_text" : "webvtt" diff --git a/web/src/lib/types/api.ts b/web/src/lib/types/api.ts index c34acc16..c6250e8d 100644 --- a/web/src/lib/types/api.ts +++ b/web/src/lib/types/api.ts @@ -73,6 +73,7 @@ export type CobaltLocalProcessingResponse = { type: string, // mimetype filename: string, metadata?: CobaltFileMetadata, + subtitles?: boolean, }, audio?: { From a5838f3c05a3392a31c939c12f647739ef2a5544 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 20 Jun 2025 18:16:32 +0600 Subject: [PATCH 042/138] api/stream/types: add subtitles & metadata to remux --- api/src/stream/types.js | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/api/src/stream/types.js b/api/src/stream/types.js index 0c71ae2d..7b5a5774 100644 --- a/api/src/stream/types.js +++ b/api/src/stream/types.js @@ -189,6 +189,8 @@ const remux = async (streamInfo, res) => { ); try { + const format = streamInfo.filename.split('.').pop(); + let args = [ '-loglevel', '-8', '-headers', toRawHeaders(getHeaders(streamInfo.service)), @@ -198,10 +200,16 @@ const remux = async (streamInfo, res) => { args.push('-seekable', '0') } - args.push( - '-i', streamInfo.urls, - '-c:v', 'copy', - ) + args.push('-i', streamInfo.urls); + + if (streamInfo.subtitles) { + args.push( + '-i', streamInfo.subtitles, + '-c:s', format === 'mp4' ? 'mov_text' : 'webvtt', + ); + }; + + args.push('-c:v', 'copy'); if (streamInfo.type === "mute") { args.push('-an'); @@ -214,11 +222,14 @@ const remux = async (streamInfo, res) => { args.push('-bsf:a', 'aac_adtstoasc'); } - let format = streamInfo.filename.split('.').pop(); if (format === "mp4") { args.push('-movflags', 'faststart+frag_keyframe+empty_moov') } + if (streamInfo.metadata) { + args = args.concat(convertMetadataToFFmpeg(streamInfo.metadata)); + } + args.push('-f', format, 'pipe:3'); process = spawn(...getCommand(args), { From a44bea6b50562ade0b88d95b778678951ffb4983 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 20 Jun 2025 18:21:00 +0600 Subject: [PATCH 043/138] api/vimeo: add subtitle parsing from the mobile api --- api/src/processing/match-action.js | 4 +++- api/src/processing/match.js | 1 + api/src/processing/services/vimeo.js | 23 +++++++++++++++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index ce8a456d..a3d9230c 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -145,7 +145,9 @@ export default function({ case "vimeo": if (Array.isArray(r.urls)) { - params = { type: "merge" } + params = { type: "merge" }; + } else if (r.subtitles) { + params = { type: "remux" }; } else { responseType = "redirect"; } diff --git a/api/src/processing/match.js b/api/src/processing/match.js index 11e2b9ab..f71dd635 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -167,6 +167,7 @@ export default async function({ host, patternMatch, params, isSession, isApiKey password: patternMatch.password, quality: params.videoQuality, isAudioOnly, + subtitleLang, }); break; diff --git a/api/src/processing/services/vimeo.js b/api/src/processing/services/vimeo.js index 8d704771..7a65f17a 100644 --- a/api/src/processing/services/vimeo.js +++ b/api/src/processing/services/vimeo.js @@ -40,7 +40,7 @@ const compareQuality = (rendition, requestedQuality) => { return Math.abs(quality - requestedQuality); } -const getDirectLink = (data, quality) => { +const getDirectLink = async (data, quality, subtitleLang) => { if (!data.files) return; const match = data.files @@ -56,8 +56,23 @@ const getDirectLink = (data, quality) => { if (!match) return; + let subtitles; + if (subtitleLang && data.config_url) { + const config = await fetch(data.config_url) + .then(r => r.json()) + .catch(() => {}); + + if (config && config.request?.text_tracks?.length) { + subtitles = config.request.text_tracks.find( + t => t.lang.startsWith(subtitleLang) + ); + subtitles = new URL(subtitles.url, "https://player.vimeo.com/").toString(); + } + } + return { urls: match.link, + subtitles, filenameAttributes: { resolution: `${match.width}x${match.height}`, qualityLabel: match.rendition, @@ -143,7 +158,7 @@ export default async function(obj) { response = await getHLS(info.config_url, { ...obj, quality }); } - if (!response) response = getDirectLink(info, quality); + if (!response) response = await getDirectLink(info, quality, obj.subtitleLang); if (!response) response = { error: "fetch.empty" }; if (response.error) { @@ -155,6 +170,10 @@ export default async function(obj) { artist: info.user.name, }; + if (response.subtitles) { + fileMetadata.sublanguage = obj.subtitleLang; + } + return merge( { fileMetadata, From 17ab8dd709d2fdabe0ab3df6881d73f25addef9e Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 20 Jun 2025 18:30:39 +0600 Subject: [PATCH 044/138] web/queue: add subtitles independently from remux type so that you can have a mute video with subtitles --- web/src/lib/task-manager/queue.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/src/lib/task-manager/queue.ts b/web/src/lib/task-manager/queue.ts index b47fbe60..db0b9531 100644 --- a/web/src/lib/task-manager/queue.ts +++ b/web/src/lib/task-manager/queue.ts @@ -68,17 +68,17 @@ const makeRemuxArgs = (info: CobaltLocalProcessingResponse) => { if (["merge", "remux"].includes(info.type)) { ffargs.push("-c:a", "copy"); - - if (info.output.subtitles) { - ffargs.push( - "-c:s", - info.output.filename.endsWith(".mp4") ? "mov_text" : "webvtt" - ); - } } else if (info.type === "mute") { ffargs.push("-an"); } + if (info.output.subtitles) { + ffargs.push( + "-c:s", + info.output.filename.endsWith(".mp4") ? "mov_text" : "webvtt" + ); + } + ffargs.push( ...(info.output.metadata ? ffmpegMetadataArgs(info.output.metadata) : []) ); From ab526c234efa9fb97506bd82dbd7ec20c853686d Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 20 Jun 2025 18:59:35 +0600 Subject: [PATCH 045/138] api/loom: add transcription subtitles since there's no language selection (at all), we just add the only transcription if a user wants subtitles --- api/src/processing/match-action.js | 9 +++++- api/src/processing/match.js | 3 +- api/src/processing/services/loom.js | 44 ++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index a3d9230c..5e77e177 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -161,6 +161,14 @@ export default function({ } break; + case "loom": + if (r.subtitles) { + params = { type: "remux" }; + } else { + responseType = "redirect"; + } + break; + case "ok": case "vk": case "tiktok": @@ -174,7 +182,6 @@ export default function({ case "pinterest": case "streamable": case "snapchat": - case "loom": case "twitch": responseType = "redirect"; break; diff --git a/api/src/processing/match.js b/api/src/processing/match.js index f71dd635..220d9593 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -235,7 +235,8 @@ export default async function({ host, patternMatch, params, isSession, isApiKey case "loom": r = await loom({ - id: patternMatch.id + id: patternMatch.id, + subtitleLang, }); break; diff --git a/api/src/processing/services/loom.js b/api/src/processing/services/loom.js index 749f6e8f..64382108 100644 --- a/api/src/processing/services/loom.js +++ b/api/src/processing/services/loom.js @@ -50,7 +50,42 @@ async function fromRawURL(id) { } } -export default async function({ id }) { +async function getTranscript(id) { + const gql = await fetch(`https://www.loom.com/graphql`, { + method: "POST", + headers: craftHeaders(id), + body: JSON.stringify({ + operationName: "FetchVideoTranscriptForFetchTranscript", + variables: { + videoId: id, + password: null, + }, + query: ` + query FetchVideoTranscriptForFetchTranscript($videoId: ID!, $password: String) { + fetchVideoTranscript(videoId: $videoId, password: $password) { + ... on VideoTranscriptDetails { + captions_source_url + language + __typename + } + ... on GenericError { + message + __typename + } + __typename + } + }`, + }) + }) + .then(r => r.status === 200 && r.json()) + .catch(() => {}); + + if (gql?.data?.fetchVideoTranscript?.captions_source_url?.includes('.vtt?')) { + return gql.data.fetchVideoTranscript.captions_source_url; + } +} + +export default async function({ id, subtitleLang }) { let url = await fromTranscodedURL(id); url ??= await fromRawURL(id); @@ -58,8 +93,15 @@ export default async function({ id }) { return { error: "fetch.empty" } } + let subtitles; + if (subtitleLang) { + const transcript = await getTranscript(id); + if (transcript) subtitles = transcript; + } + return { urls: url, + subtitles, filename: `loom_${id}.mp4`, audioFilename: `loom_${id}_audio` } From d18b22e7ed2c32aecb6ba65150fa3ef2862c1405 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 20 Jun 2025 19:53:01 +0600 Subject: [PATCH 046/138] api/processing/request: return a unique error code --- api/src/processing/request.js | 4 ++-- web/i18n/en/error/api.json | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/src/processing/request.js b/api/src/processing/request.js index 61f801e0..95c83659 100644 --- a/api/src/processing/request.js +++ b/api/src/processing/request.js @@ -11,7 +11,7 @@ export function createResponse(responseType, responseData) { body: { status: "error", error: { - code: code || "error.api.fetch.critical", + code: code || "error.api.fetch.critical.core", }, critical: true } @@ -109,7 +109,7 @@ export function createResponse(responseType, responseData) { } } } catch { - return internalError() + return internalError(); } } diff --git a/web/i18n/en/error/api.json b/web/i18n/en/error/api.json index 797d920b..f0ece2d6 100644 --- a/web/i18n/en/error/api.json +++ b/web/i18n/en/error/api.json @@ -31,6 +31,7 @@ "fetch.fail": "something went wrong when fetching info from {{ service }} and i couldn't get anything for you. if this issue sticks, please report it!", "fetch.critical": "the {{ service }} module returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!", + "fetch.critical.core": "one of core modules returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!", "fetch.empty": "couldn't find any media that i could download for you. are you sure you pasted the right link?", "fetch.rate": "the processing instance got rate limited by {{ service }}. try again in a few seconds!", "fetch.short_link": "couldn't get info from the short link. are you sure it works? if it does and you still get this error, please report the issue!", From aff2d22edca0f20680f36aaae181e5cef7fff048 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 20 Jun 2025 20:05:17 +0600 Subject: [PATCH 047/138] api/language-codes: add reverse lookup (2 to 1) --- api/src/misc/language-codes.js | 53 ++++++++++++++++++++++++++++++ api/src/misc/subtitle-lang.js | 44 ------------------------- api/src/processing/match-action.js | 8 ++--- 3 files changed, 57 insertions(+), 48 deletions(-) create mode 100644 api/src/misc/language-codes.js delete mode 100644 api/src/misc/subtitle-lang.js diff --git a/api/src/misc/language-codes.js b/api/src/misc/language-codes.js new file mode 100644 index 00000000..c18006b5 --- /dev/null +++ b/api/src/misc/language-codes.js @@ -0,0 +1,53 @@ +// converted from this file https://www.loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt +const iso639_1to2 = { + 'aa': 'aar', 'ab': 'abk', 'af': 'afr', 'ak': 'aka', 'sq': 'sqi', + 'am': 'amh', 'ar': 'ara', 'an': 'arg', 'hy': 'hye', 'as': 'asm', + 'av': 'ava', 'ae': 'ave', 'ay': 'aym', 'az': 'aze', 'ba': 'bak', + 'bm': 'bam', 'eu': 'eus', 'be': 'bel', 'bn': 'ben', 'bi': 'bis', + 'bs': 'bos', 'br': 'bre', 'bg': 'bul', 'my': 'mya', 'ca': 'cat', + 'ch': 'cha', 'ce': 'che', 'zh': 'zho', 'cu': 'chu', 'cv': 'chv', + 'kw': 'cor', 'co': 'cos', 'cr': 'cre', 'cs': 'ces', 'da': 'dan', + 'dv': 'div', 'nl': 'nld', 'dz': 'dzo', 'en': 'eng', 'eo': 'epo', + 'et': 'est', 'ee': 'ewe', 'fo': 'fao', 'fj': 'fij', 'fi': 'fin', + 'fr': 'fra', 'fy': 'fry', 'ff': 'ful', 'ka': 'kat', 'de': 'deu', + 'gd': 'gla', 'ga': 'gle', 'gl': 'glg', 'gv': 'glv', 'el': 'ell', + 'gn': 'grn', 'gu': 'guj', 'ht': 'hat', 'ha': 'hau', 'he': 'heb', + 'hz': 'her', 'hi': 'hin', 'ho': 'hmo', 'hr': 'hrv', 'hu': 'hun', + 'ig': 'ibo', 'is': 'isl', 'io': 'ido', 'ii': 'iii', 'iu': 'iku', + 'ie': 'ile', 'ia': 'ina', 'id': 'ind', 'ik': 'ipk', 'it': 'ita', + 'jv': 'jav', 'ja': 'jpn', 'kl': 'kal', 'kn': 'kan', 'ks': 'kas', + 'kr': 'kau', 'kk': 'kaz', 'km': 'khm', 'ki': 'kik', 'rw': 'kin', + 'ky': 'kir', 'kv': 'kom', 'kg': 'kon', 'ko': 'kor', 'kj': 'kua', + 'ku': 'kur', 'lo': 'lao', 'la': 'lat', 'lv': 'lav', 'li': 'lim', + 'ln': 'lin', 'lt': 'lit', 'lb': 'ltz', 'lu': 'lub', 'lg': 'lug', + 'mk': 'mkd', 'mh': 'mah', 'ml': 'mal', 'mi': 'mri', 'mr': 'mar', + 'ms': 'msa', 'mg': 'mlg', 'mt': 'mlt', 'mn': 'mon', 'na': 'nau', + 'nv': 'nav', 'nr': 'nbl', 'nd': 'nde', 'ng': 'ndo', 'ne': 'nep', + 'nn': 'nno', 'nb': 'nob', 'no': 'nor', 'ny': 'nya', 'oc': 'oci', + 'oj': 'oji', 'or': 'ori', 'om': 'orm', 'os': 'oss', 'pa': 'pan', + 'fa': 'fas', 'pi': 'pli', 'pl': 'pol', 'pt': 'por', 'ps': 'pus', + 'qu': 'que', 'rm': 'roh', 'ro': 'ron', 'rn': 'run', 'ru': 'rus', + 'sg': 'sag', 'sa': 'san', 'si': 'sin', 'sk': 'slk', 'sl': 'slv', + 'se': 'sme', 'sm': 'smo', 'sn': 'sna', 'sd': 'snd', 'so': 'som', + 'st': 'sot', 'es': 'spa', 'sc': 'srd', 'sr': 'srp', 'ss': 'ssw', + 'su': 'sun', 'sw': 'swa', 'sv': 'swe', 'ty': 'tah', 'ta': 'tam', + 'tt': 'tat', 'te': 'tel', 'tg': 'tgk', 'tl': 'tgl', 'th': 'tha', + 'bo': 'bod', 'ti': 'tir', 'to': 'ton', 'tn': 'tsn', 'ts': 'tso', + 'tk': 'tuk', 'tr': 'tur', 'tw': 'twi', 'ug': 'uig', 'uk': 'ukr', + 'ur': 'urd', 'uz': 'uzb', 've': 'ven', 'vi': 'vie', 'vo': 'vol', + 'cy': 'cym', 'wa': 'wln', 'wo': 'wol', 'xh': 'xho', 'yi': 'yid', + 'yo': 'yor', 'za': 'zha', 'zu': 'zul', +} + +const iso639_2to1 = Object.fromEntries( + Object.entries(iso639_1to2).map(([k, v]) => [v, k]) +); + +const maps = { + 2: iso639_1to2, + 3: iso639_2to1, +} + +export const convertLanguageCode = (code) => { + return maps[code.length]?.[code.toLowerCase()] || null; +} diff --git a/api/src/misc/subtitle-lang.js b/api/src/misc/subtitle-lang.js deleted file mode 100644 index 907c5a57..00000000 --- a/api/src/misc/subtitle-lang.js +++ /dev/null @@ -1,44 +0,0 @@ -// converted from this file https://www.loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt -const LANGUAGE_CODES = { - 'aa': 'aar', 'ab': 'abk', 'af': 'afr', 'ak': 'aka', 'sq': 'sqi', - 'am': 'amh', 'ar': 'ara', 'an': 'arg', 'hy': 'hye', 'as': 'asm', - 'av': 'ava', 'ae': 'ave', 'ay': 'aym', 'az': 'aze', 'ba': 'bak', - 'bm': 'bam', 'eu': 'eus', 'be': 'bel', 'bn': 'ben', 'bi': 'bis', - 'bs': 'bos', 'br': 'bre', 'bg': 'bul', 'my': 'mya', 'ca': 'cat', - 'ch': 'cha', 'ce': 'che', 'zh': 'zho', 'cu': 'chu', 'cv': 'chv', - 'kw': 'cor', 'co': 'cos', 'cr': 'cre', 'cs': 'ces', 'da': 'dan', - 'dv': 'div', 'nl': 'nld', 'dz': 'dzo', 'en': 'eng', 'eo': 'epo', - 'et': 'est', 'ee': 'ewe', 'fo': 'fao', 'fj': 'fij', 'fi': 'fin', - 'fr': 'fra', 'fy': 'fry', 'ff': 'ful', 'ka': 'kat', 'de': 'deu', - 'gd': 'gla', 'ga': 'gle', 'gl': 'glg', 'gv': 'glv', 'el': 'ell', - 'gn': 'grn', 'gu': 'guj', 'ht': 'hat', 'ha': 'hau', 'he': 'heb', - 'hz': 'her', 'hi': 'hin', 'ho': 'hmo', 'hr': 'hrv', 'hu': 'hun', - 'ig': 'ibo', 'is': 'isl', 'io': 'ido', 'ii': 'iii', 'iu': 'iku', - 'ie': 'ile', 'ia': 'ina', 'id': 'ind', 'ik': 'ipk', 'it': 'ita', - 'jv': 'jav', 'ja': 'jpn', 'kl': 'kal', 'kn': 'kan', 'ks': 'kas', - 'kr': 'kau', 'kk': 'kaz', 'km': 'khm', 'ki': 'kik', 'rw': 'kin', - 'ky': 'kir', 'kv': 'kom', 'kg': 'kon', 'ko': 'kor', 'kj': 'kua', - 'ku': 'kur', 'lo': 'lao', 'la': 'lat', 'lv': 'lav', 'li': 'lim', - 'ln': 'lin', 'lt': 'lit', 'lb': 'ltz', 'lu': 'lub', 'lg': 'lug', - 'mk': 'mkd', 'mh': 'mah', 'ml': 'mal', 'mi': 'mri', 'mr': 'mar', - 'ms': 'msa', 'mg': 'mlg', 'mt': 'mlt', 'mn': 'mon', 'na': 'nau', - 'nv': 'nav', 'nr': 'nbl', 'nd': 'nde', 'ng': 'ndo', 'ne': 'nep', - 'nn': 'nno', 'nb': 'nob', 'no': 'nor', 'ny': 'nya', 'oc': 'oci', - 'oj': 'oji', 'or': 'ori', 'om': 'orm', 'os': 'oss', 'pa': 'pan', - 'fa': 'fas', 'pi': 'pli', 'pl': 'pol', 'pt': 'por', 'ps': 'pus', - 'qu': 'que', 'rm': 'roh', 'ro': 'ron', 'rn': 'run', 'ru': 'rus', - 'sg': 'sag', 'sa': 'san', 'si': 'sin', 'sk': 'slk', 'sl': 'slv', - 'se': 'sme', 'sm': 'smo', 'sn': 'sna', 'sd': 'snd', 'so': 'som', - 'st': 'sot', 'es': 'spa', 'sc': 'srd', 'sr': 'srp', 'ss': 'ssw', - 'su': 'sun', 'sw': 'swa', 'sv': 'swe', 'ty': 'tah', 'ta': 'tam', - 'tt': 'tat', 'te': 'tel', 'tg': 'tgk', 'tl': 'tgl', 'th': 'tha', - 'bo': 'bod', 'ti': 'tir', 'to': 'ton', 'tn': 'tsn', 'ts': 'tso', - 'tk': 'tuk', 'tr': 'tur', 'tw': 'twi', 'ug': 'uig', 'uk': 'ukr', - 'ur': 'urd', 'uz': 'uzb', 've': 'ven', 'vi': 'vie', 'vo': 'vol', - 'cy': 'cym', 'wa': 'wln', 'wo': 'wol', 'xh': 'xho', 'yi': 'yid', - 'yo': 'yor', 'za': 'zha', 'zu': 'zul' -} - -export const convertSubtitleLanguage = (code) => { - return LANGUAGE_CODES[code.toLowerCase()] || null; -} diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index 5e77e177..0124d208 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -4,7 +4,7 @@ import { createResponse } from "./request.js"; import { audioIgnore } from "./service-config.js"; import { createStream } from "../stream/manage.js"; import { splitFilenameExtension } from "../misc/utils.js"; -import { convertSubtitleLanguage } from "../misc/subtitle-lang.js"; +import { convertLanguageCode } from "../misc/language-codes.js"; const extraProcessingTypes = ["merge", "remux", "mute", "audio", "gif"]; @@ -248,10 +248,10 @@ export default function({ responseType = "local-processing"; } - // extractors return ISO 639-1 language codes, + // extractors usually return ISO 639-1 language codes, // but video players expect ISO 639-2, so we convert them here - if (defaultParams.fileMetadata?.sublanguage) { - const code = convertSubtitleLanguage(defaultParams.fileMetadata.sublanguage); + if (defaultParams.fileMetadata?.sublanguage?.length === 2) { + const code = convertLanguageCode(defaultParams.fileMetadata.sublanguage); if (code) { defaultParams.fileMetadata.sublanguage = code; } else { From 630e4a6e0d0c81ffa27d5c559ddc53c22b240b82 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 20 Jun 2025 20:07:50 +0600 Subject: [PATCH 048/138] api/tiktok: add support for subtitles --- api/src/processing/match-action.js | 7 ++++++- api/src/processing/match.js | 1 + api/src/processing/services/tiktok.js | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index 0124d208..f8cc39a9 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -169,9 +169,14 @@ export default function({ } break; + case "tiktok": + params = { + type: r.subtitles ? "remux" : "proxy" + }; + break; + case "ok": case "vk": - case "tiktok": case "xiaohongshu": params = { type: "proxy" }; break; diff --git a/api/src/processing/match.js b/api/src/processing/match.js index 220d9593..1d91f327 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -150,6 +150,7 @@ export default async function({ host, patternMatch, params, isSession, isApiKey isAudioOnly, h265: params.allowH265, alwaysProxy: params.alwaysProxy, + subtitleLang, }); break; diff --git a/api/src/processing/services/tiktok.js b/api/src/processing/services/tiktok.js index 93e07c50..4524596d 100644 --- a/api/src/processing/services/tiktok.js +++ b/api/src/processing/services/tiktok.js @@ -4,6 +4,7 @@ import { extract, normalizeURL } from "../url.js"; import { genericUserAgent } from "../../config.js"; import { updateCookie } from "../cookie/manager.js"; import { createStream } from "../../stream/manage.js"; +import { convertLanguageCode } from "../../misc/language-codes.js"; const shortDomain = "https://vt.tiktok.com/"; @@ -97,8 +98,23 @@ export default async function(obj) { } if (video) { + let subtitles, fileMetadata; + if (obj.subtitleLang && detail?.video?.subtitleInfos?.length) { + const langCode = convertLanguageCode(obj.subtitleLang); + const subtitle = detail?.video?.subtitleInfos.find( + s => s.LanguageCodeName.startsWith(langCode) && s.Format === "webvtt" + ) + if (subtitle) { + subtitles = subtitle.Url; + fileMetadata = { + sublanguage: langCode, + } + } + } return { urls: video, + subtitles, + fileMetadata, filename: videoFilename, headers: { cookie } } From 2c0a1b699061b0cb78434c5177b7310d798df800 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 20 Jun 2025 20:35:23 +0600 Subject: [PATCH 049/138] web/i18n/settings: update subtitles description --- web/i18n/en/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/i18n/en/settings.json b/web/i18n/en/settings.json index 974563d4..458e8e6c 100644 --- a/web/i18n/en/settings.json +++ b/web/i18n/en/settings.json @@ -65,7 +65,7 @@ "subtitles": "subtitles", "subtitles.title": "preferred subtitle language", - "subtitles.description": "cobalt will add subtitles to the downloaded file in the preferred language if they're available. if not, nothing will be added.", + "subtitles.description": "cobalt will add subtitles to the downloaded file in the preferred language if they're available.\n\nsome services don't have a language selection, and if that's the case, cobalt will add the only available subtitle track if you have any language selected.", "subtitles.none": "none", "audio.youtube.better_audio": "youtube audio quality", From a998a5720cda9ba374de5119014e5bc9cbcf0c18 Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 22 Jun 2025 15:23:39 +0600 Subject: [PATCH 050/138] web/queue: refactor media icon selection --- .../components/queue/ProcessingQueueItem.svelte | 2 ++ web/src/lib/task-manager/queue.ts | 17 ++++------------- web/src/lib/types/workers.ts | 2 +- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/web/src/components/queue/ProcessingQueueItem.svelte b/web/src/components/queue/ProcessingQueueItem.svelte index 5ab4e03d..c8c2e79c 100644 --- a/web/src/components/queue/ProcessingQueueItem.svelte +++ b/web/src/components/queue/ProcessingQueueItem.svelte @@ -21,11 +21,13 @@ import IconDownload from "@tabler/icons-svelte/IconDownload.svelte"; import IconExclamationCircle from "@tabler/icons-svelte/IconExclamationCircle.svelte"; + import IconFile from "@tabler/icons-svelte/IconFile.svelte"; import IconMovie from "@tabler/icons-svelte/IconMovie.svelte"; import IconMusic from "@tabler/icons-svelte/IconMusic.svelte"; import IconPhoto from "@tabler/icons-svelte/IconPhoto.svelte"; const itemIcons = { + file: IconFile, video: IconMovie, audio: IconMusic, image: IconPhoto, diff --git a/web/src/lib/task-manager/queue.ts b/web/src/lib/task-manager/queue.ts index db0b9531..164be632 100644 --- a/web/src/lib/task-manager/queue.ts +++ b/web/src/lib/task-manager/queue.ts @@ -8,14 +8,13 @@ import { uuid } from "$lib/util"; import type { CobaltQueueItem } from "$lib/types/queue"; import type { CobaltCurrentTasks } from "$lib/types/task-manager"; -import type { CobaltPipelineItem, CobaltPipelineResultFileType } from "$lib/types/workers"; +import { resultFileTypes, type CobaltPipelineItem, type CobaltPipelineResultFileType } from "$lib/types/workers"; import type { CobaltLocalProcessingResponse, CobaltSaveRequestBody } from "$lib/types/api"; export const getMediaType = (type: string) => { - const kind = type.split('/')[0]; + const kind = type.split('/')[0] as CobaltPipelineResultFileType; - // can't use .includes() here for some reason - if (kind === "video" || kind === "audio" || kind === "image") { + if (resultFileTypes.includes(kind)) { return kind; } } @@ -55,14 +54,6 @@ export const createRemuxPipeline = (file: File) => { } } -const mediaIcons: { [key: string]: CobaltPipelineResultFileType } = { - merge: "video", - mute: "video", - audio: "audio", - gif: "image", - remux: "video" -} - const makeRemuxArgs = (info: CobaltLocalProcessingResponse) => { const ffargs = ["-c:v", "copy"]; @@ -211,7 +202,7 @@ export const createSavePipeline = ( originalRequest: request, filename: info.output.filename, mimeType: info.output.type, - mediaType: mediaIcons[info.type], + mediaType: getMediaType(info.output.type) || "file", }); openQueuePopover(); diff --git a/web/src/lib/types/workers.ts b/web/src/lib/types/workers.ts index 56e44baa..b65abf69 100644 --- a/web/src/lib/types/workers.ts +++ b/web/src/lib/types/workers.ts @@ -1,7 +1,7 @@ import type { FileInfo } from "$lib/types/libav"; import type { UUID } from "./queue"; -export const resultFileTypes = ["video", "audio", "image"] as const; +export const resultFileTypes = ["video", "audio", "image", "file"] as const; export type CobaltPipelineResultFileType = typeof resultFileTypes[number]; From a6b599a8283068f92d002f077acac6f7c68e19a3 Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 22 Jun 2025 16:20:27 +0600 Subject: [PATCH 051/138] api/schema: transform localProcessing to enum --- api/src/processing/schema.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/src/processing/schema.js b/api/src/processing/schema.js index ee77ade2..604b4cfd 100644 --- a/api/src/processing/schema.js +++ b/api/src/processing/schema.js @@ -34,6 +34,10 @@ export const apiSchema = z.object({ ["max", "4320", "2160", "1440", "1080", "720", "480", "360", "240", "144"] ).default("1080"), + localProcessing: z.enum( + ["disabled", "preferred", "forced"] + ).default("disabled"), + youtubeDubLang: z.string() .min(2) .max(8) @@ -53,12 +57,11 @@ export const apiSchema = z.object({ tiktokFullAudio: z.boolean().default(false), alwaysProxy: z.boolean().default(false), - localProcessing: z.boolean().default(false), youtubeHLS: z.boolean().default(false), youtubeBetterAudio: z.boolean().default(false), - // temporarily kept for backwards compatibility with cobalt 10 schema + // TODO: remove after backwards compatibility period twitterGif: z.boolean().default(false), tiktokH265: z.boolean().default(false), }) From 28ab2747ceb07a8638c7abdf22564cf3646ef545 Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 22 Jun 2025 16:21:37 +0600 Subject: [PATCH 052/138] api/match-action: support forced local processing --- api/src/processing/match-action.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index f8cc39a9..0d65234c 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -20,7 +20,7 @@ export default function({ requestIP, audioBitrate, alwaysProxy, - localProcessing + localProcessing, }) { let action, responseType = "tunnel", @@ -242,15 +242,20 @@ export default function({ defaultParams.filename += `.${audioFormat}`; } - if (alwaysProxy && responseType === "redirect") { + if ((alwaysProxy || localProcessing === "forced") && responseType === "redirect") { responseType = "tunnel"; params.type = "proxy"; } // TODO: add support for HLS // (very painful) - if (localProcessing && !params.isHLS && extraProcessingTypes.includes(params.type)) { - responseType = "local-processing"; + if (!params.isHLS) { + const isPreferredWithExtra = + localProcessing === "preferred" && extraProcessingTypes.includes(params.type); + + if (localProcessing === "forced" || isPreferredWithExtra) { + responseType = "local-processing"; + } } // extractors usually return ISO 639-1 language codes, From ac85ce86c0044f346fb015c0e38319e99570eee2 Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 22 Jun 2025 16:21:55 +0600 Subject: [PATCH 053/138] api/processing/request: backwards compat with boolean localProcessing --- api/src/processing/request.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/src/processing/request.js b/api/src/processing/request.js index 95c83659..62a4d781 100644 --- a/api/src/processing/request.js +++ b/api/src/processing/request.js @@ -114,6 +114,11 @@ export function createResponse(responseType, responseData) { } export function normalizeRequest(request) { + // TODO: remove after backwards compatibility period + if ("localProcessing" in request && typeof request.localProcessing === "boolean") { + request.localProcessing = request.localProcessing ? "preferred" : "disabled"; + } + return apiSchema.safeParseAsync(request).catch(() => ( { success: false } )); From a4d5f5b38035c9c97a728748c45e98629ae6b2e6 Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 22 Jun 2025 16:28:18 +0600 Subject: [PATCH 054/138] web/settings: migrate boolean localProcessing to enum --- web/src/lib/settings/defaults.ts | 3 ++- web/src/lib/settings/migrate.ts | 15 +++++++++++++++ web/src/lib/types/settings/v6.ts | 4 +++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/web/src/lib/settings/defaults.ts b/web/src/lib/settings/defaults.ts index a082adf6..ce3114ec 100644 --- a/web/src/lib/settings/defaults.ts +++ b/web/src/lib/settings/defaults.ts @@ -22,7 +22,8 @@ const defaultSettings: CobaltSettings = { }, save: { alwaysProxy: false, - localProcessing: device.supports.defaultLocalProcessing || false, + localProcessing: + device.supports.defaultLocalProcessing ? "preferred" : "disabled", audioBitrate: "128", audioFormat: "mp3", disableMetadata: false, diff --git a/web/src/lib/settings/migrate.ts b/web/src/lib/settings/migrate.ts index 60b1028a..d033a6f9 100644 --- a/web/src/lib/settings/migrate.ts +++ b/web/src/lib/settings/migrate.ts @@ -5,6 +5,7 @@ import type { CobaltSettingsV3, CobaltSettingsV4, CobaltSettingsV5, + CobaltSettingsV6, } from "$lib/types/settings"; import { getBrowserLanguage } from "$lib/settings/audio-sub-language"; @@ -80,6 +81,20 @@ const migrations: Record = { return out as AllPartialSettingsWithSchema; }, + + [6]: (settings: AllPartialSettingsWithSchema) => { + const out = settings as RecursivePartial; + out.schemaVersion = 6; + + if (settings?.save) { + if ("localProcessing" in settings.save) { + out.save!.localProcessing = + settings.save.localProcessing ? "preferred" : "disabled"; + } + } + + return out as AllPartialSettingsWithSchema; + }, }; export const migrate = (settings: AllPartialSettingsWithSchema): PartialSettings => { diff --git a/web/src/lib/types/settings/v6.ts b/web/src/lib/types/settings/v6.ts index 762d6a77..0a933e67 100644 --- a/web/src/lib/types/settings/v6.ts +++ b/web/src/lib/types/settings/v6.ts @@ -2,10 +2,12 @@ import type { SubtitleLang } from "$lib/settings/audio-sub-language"; import type { CobaltSettingsV5 } from "$lib/types/settings/v5"; export const youtubeVideoContainerOptions = ["auto", "mp4", "webm", "mkv"] as const; +export const localProcessingOptions = ["disabled", "preferred", "forced"] as const; export type CobaltSettingsV6 = Omit & { schemaVersion: 6, - save: CobaltSettingsV5['save'] & { + save: Omit & { + localProcessing: typeof localProcessingOptions[number], youtubeVideoContainer: typeof youtubeVideoContainerOptions[number]; subtitleLang: SubtitleLang, }, From 885398955ffcee487459aaf5240824e7432c0a24 Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 22 Jun 2025 16:29:47 +0600 Subject: [PATCH 055/138] web/settings/local: transform the media processing setting to a switcher --- web/i18n/en/settings.json | 8 +++++--- web/src/routes/settings/local/+page.svelte | 20 ++++++++++++++------ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/web/i18n/en/settings.json b/web/i18n/en/settings.json index 458e8e6c..dac6b62a 100644 --- a/web/i18n/en/settings.json +++ b/web/i18n/en/settings.json @@ -143,9 +143,11 @@ "advanced.settings_data": "settings data", "advanced.local_storage": "local storage", - "local.saving": "media processing", - "local.saving.title": "download & process media locally", - "local.saving.description": "when downloading media, remuxing and transcoding will be done on-device instead of the cloud. you'll see detailed progress in the processing queue. processing instances may enforce this feature to save resources.\n\nexclusive on-device features are not affected by this toggle, they always run locally.", + "local.saving": "local media processing", + "local.saving.description": "when downloading media, remuxing and transcoding will be done on-device instead of the cloud. you'll see detailed progress in the processing queue.\n\ndisabled: local processing will not be used. processing instances can enforce local processing, so this option may not have effect.\npreferred: media that requires extra processing will be downloaded through the processing queue, but the rest of media will be downloaded by your browser's download manager.\nforced: all media will always be proxied and downloaded through the processing queue.\n\nexclusive on-device features are not affected by this setting, they always run locally.", + "local.saving.disabled": "disabled", + "local.saving.preferred": "preferred", + "local.saving.forced": "forced", "local.webcodecs": "webcodecs", "local.webcodecs.title": "use webcodecs for on-device processing", diff --git a/web/src/routes/settings/local/+page.svelte b/web/src/routes/settings/local/+page.svelte index babff20e..656e7fa3 100644 --- a/web/src/routes/settings/local/+page.svelte +++ b/web/src/routes/settings/local/+page.svelte @@ -1,18 +1,26 @@ - + + {#each localProcessingOptions as value} + + {$t(`settings.local.saving.${value}`)} + + {/each} + {#if env.ENABLE_WEBCODECS} From 61e0862b10548ad83cbb6fd60a1de95c8d2ad419 Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 22 Jun 2025 16:31:09 +0600 Subject: [PATCH 056/138] web/types/api: add proxy local processing type --- web/src/lib/types/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/types/api.ts b/web/src/lib/types/api.ts index c6250e8d..2091a8e2 100644 --- a/web/src/lib/types/api.ts +++ b/web/src/lib/types/api.ts @@ -60,7 +60,7 @@ export type CobaltFileMetadata = Record< typeof CobaltFileMetadataKeys[number], string | undefined >; -export type CobaltLocalProcessingType = 'merge' | 'mute' | 'audio' | 'gif' | 'remux'; +export type CobaltLocalProcessingType = 'merge' | 'mute' | 'audio' | 'gif' | 'remux' | 'proxy'; export type CobaltLocalProcessingResponse = { status: CobaltResponseType.LocalProcessing, From f883887e4a7fd263d21a2844d54f60ec236031ce Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 22 Jun 2025 16:33:00 +0600 Subject: [PATCH 057/138] web/queue: don't try to add a remux task if response type is proxy --- web/src/lib/task-manager/queue.ts | 64 ++++++++++++++++--------------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/web/src/lib/task-manager/queue.ts b/web/src/lib/task-manager/queue.ts index 164be632..7b507426 100644 --- a/web/src/lib/task-manager/queue.ts +++ b/web/src/lib/task-manager/queue.ts @@ -156,43 +156,45 @@ export const createSavePipeline = ( }); } - let ffargs: string[]; - let workerType: 'encode' | 'remux'; + if (info.type !== "proxy") { + let ffargs: string[]; + let workerType: 'encode' | 'remux'; - if (["merge", "mute", "remux"].includes(info.type)) { - workerType = "remux"; - ffargs = makeRemuxArgs(info); - } else if (info.type === "audio") { - const args = makeAudioArgs(info); + if (["merge", "mute", "remux"].includes(info.type)) { + workerType = "remux"; + ffargs = makeRemuxArgs(info); + } else if (info.type === "audio") { + const args = makeAudioArgs(info); - if (!args) { + if (!args) { + return showError("pipeline.missing_response_data"); + } + + workerType = "encode"; + ffargs = args; + } else if (info.type === "gif") { + workerType = "encode"; + ffargs = makeGifArgs(); + } else { + console.error("unknown work type: " + info.type); return showError("pipeline.missing_response_data"); } - workerType = "encode"; - ffargs = args; - } else if (info.type === "gif") { - workerType = "encode"; - ffargs = makeGifArgs(); - } else { - console.error("unknown work type: " + info.type); - return showError("pipeline.missing_response_data"); - } - - pipeline.push({ - worker: workerType, - workerId: uuid(), - parentId, - dependsOn: pipeline.map(w => w.workerId), - workerArgs: { - files: [], - ffargs, - output: { - type: info.output.type, - format: info.output.filename.split(".").pop(), + pipeline.push({ + worker: workerType, + workerId: uuid(), + parentId, + dependsOn: pipeline.map(w => w.workerId), + workerArgs: { + files: [], + ffargs, + output: { + type: info.output.type, + format: info.output.filename.split(".").pop(), + }, }, - }, - }); + }); + } addItem({ id: parentId, From 05fb1601c85d9018c0e9baae580d9f184314116e Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 22 Jun 2025 20:06:28 +0600 Subject: [PATCH 058/138] api/match: update forcing local processing via env --- api/src/processing/match.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/api/src/processing/match.js b/api/src/processing/match.js index 1d91f327..568b0fd1 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -309,11 +309,12 @@ export default async function({ host, patternMatch, params, isSession, isApiKey } let localProcessing = params.localProcessing; - const lpEnv = env.forceLocalProcessing; + const shouldForceLocal = lpEnv === "always" || (lpEnv === "session" && isSession); + const localDisabled = (!localProcessing || localProcessing === "none"); - if (lpEnv === "always" || (lpEnv === "session" && isSession)) { - localProcessing = true; + if (shouldForceLocal && localDisabled) { + localProcessing = "preferred"; } return matchAction({ From 0fca9c440ca4da6bdd11b39e29af7ecdc22ea341 Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 22 Jun 2025 20:07:37 +0600 Subject: [PATCH 059/138] api/schema: remove deprecated variables --- api/src/processing/schema.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/api/src/processing/schema.js b/api/src/processing/schema.js index 604b4cfd..7570a8b0 100644 --- a/api/src/processing/schema.js +++ b/api/src/processing/schema.js @@ -60,9 +60,5 @@ export const apiSchema = z.object({ youtubeHLS: z.boolean().default(false), youtubeBetterAudio: z.boolean().default(false), - - // TODO: remove after backwards compatibility period - twitterGif: z.boolean().default(false), - tiktokH265: z.boolean().default(false), }) .strict(); From 21c4a1ebbcb25d2c65326afb7e86de199f1a2de8 Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 22 Jun 2025 20:09:48 +0600 Subject: [PATCH 060/138] api/match: set alwaysProxy to true if local processing is forced --- api/src/processing/match-action.js | 3 ++- api/src/processing/match.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index 0d65234c..b4a0dbdc 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -242,7 +242,8 @@ export default function({ defaultParams.filename += `.${audioFormat}`; } - if ((alwaysProxy || localProcessing === "forced") && responseType === "redirect") { + // alwaysProxy is set to true in match.js if localProcessing is forced + if (alwaysProxy && responseType === "redirect") { responseType = "tunnel"; params.type = "proxy"; } diff --git a/api/src/processing/match.js b/api/src/processing/match.js index 568b0fd1..90e60186 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -328,7 +328,7 @@ export default async function({ host, patternMatch, params, isSession, isApiKey convertGif: params.convertGif, requestIP, audioBitrate: params.audioBitrate, - alwaysProxy: params.alwaysProxy, + alwaysProxy: params.alwaysProxy || localProcessing === "forced", localProcessing, }) } catch { From 6d62bce92ddae7a9299b033fee6e62bc3782a90d Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 22 Jun 2025 20:12:22 +0600 Subject: [PATCH 061/138] api/match-action: don't force local-processing response for pickers cuz that won't work, at least for now --- api/src/processing/match-action.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index b4a0dbdc..b5c70409 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -250,7 +250,7 @@ export default function({ // TODO: add support for HLS // (very painful) - if (!params.isHLS) { + if (!params.isHLS && responseType !== "picker") { const isPreferredWithExtra = localProcessing === "preferred" && extraProcessingTypes.includes(params.type); From b384dc81cde67e6193d6fd872251b422da94ab0b Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 22 Jun 2025 20:12:36 +0600 Subject: [PATCH 062/138] web/error/api: add missing "the" to fetch.critical.core --- web/i18n/en/error/api.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/i18n/en/error/api.json b/web/i18n/en/error/api.json index f0ece2d6..93158b61 100644 --- a/web/i18n/en/error/api.json +++ b/web/i18n/en/error/api.json @@ -31,7 +31,7 @@ "fetch.fail": "something went wrong when fetching info from {{ service }} and i couldn't get anything for you. if this issue sticks, please report it!", "fetch.critical": "the {{ service }} module returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!", - "fetch.critical.core": "one of core modules returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!", + "fetch.critical.core": "one of the core modules returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!", "fetch.empty": "couldn't find any media that i could download for you. are you sure you pasted the right link?", "fetch.rate": "the processing instance got rate limited by {{ service }}. try again in a few seconds!", "fetch.short_link": "couldn't get info from the short link. are you sure it works? if it does and you still get this error, please report the issue!", From 599ec9dd9240cc83e904e20161e3b4852b0ef532 Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 22 Jun 2025 20:56:05 +0600 Subject: [PATCH 063/138] web/UpdateNotification: update margin & font size this also fixes position in rtl layout --- web/src/components/misc/UpdateNotification.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/misc/UpdateNotification.svelte b/web/src/components/misc/UpdateNotification.svelte index 8e8e1b54..77f3a966 100644 --- a/web/src/components/misc/UpdateNotification.svelte +++ b/web/src/components/misc/UpdateNotification.svelte @@ -46,7 +46,7 @@ padding: 8px 12px 8px 8px; pointer-events: all; gap: 8px; - margin-right: 71px; + margin: 0 64px; margin-top: calc(env(safe-area-inset-top) + 8px); box-shadow: var(--button-box-shadow), @@ -81,7 +81,7 @@ display: flex; flex-direction: column; text-align: start; - font-size: 13px; + font-size: 12.5px; } .subtext { From 44f4ea32c6c270d3f33fae805e74cab24442751f Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 24 Jun 2025 17:04:43 +0600 Subject: [PATCH 064/138] api/stream/internal: stream vk videos in chunks --- api/src/stream/internal.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/api/src/stream/internal.js b/api/src/stream/internal.js index 6d4ce318..b261fa10 100644 --- a/api/src/stream/internal.js +++ b/api/src/stream/internal.js @@ -6,6 +6,8 @@ import { handleHlsPlaylist, isHlsResponse, probeInternalHLSTunnel } from "./inte const CHUNK_SIZE = BigInt(8e6); // 8 MB const min = (a, b) => a < b ? a : b; +const serviceNeedsChunks = ["youtube", "vk"]; + async function* readChunks(streamInfo, size) { let read = 0n, chunksSinceTransplant = 0; while (read < size) { @@ -15,7 +17,7 @@ async function* readChunks(streamInfo, size) { const chunk = await request(streamInfo.url, { headers: { - ...getHeaders('youtube'), + ...getHeaders(streamInfo.service), Range: `bytes=${read}-${read + CHUNK_SIZE}` }, dispatcher: streamInfo.dispatcher, @@ -48,7 +50,7 @@ async function* readChunks(streamInfo, size) { } } -async function handleYoutubeStream(streamInfo, res) { +async function handleChunkedStream(streamInfo, res) { const { signal } = streamInfo.controller; const cleanup = () => (res.end(), closeRequest(streamInfo.controller)); @@ -56,7 +58,7 @@ async function handleYoutubeStream(streamInfo, res) { let req, attempts = 3; while (attempts--) { req = await fetch(streamInfo.url, { - headers: getHeaders('youtube'), + headers: getHeaders(streamInfo.service), method: 'HEAD', dispatcher: streamInfo.dispatcher, signal @@ -146,8 +148,8 @@ export function internalStream(streamInfo, res) { streamInfo.headers.delete('icy-metadata'); } - if (streamInfo.service === 'youtube' && !streamInfo.isHLS) { - return handleYoutubeStream(streamInfo, res); + if (serviceNeedsChunks.includes(streamInfo.service) && !streamInfo.isHLS) { + return handleChunkedStream(streamInfo, res); } return handleGenericStream(streamInfo, res); From 997b06ed0e2d2888a09cf096649fe7c9a3ee4dee Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 24 Jun 2025 17:06:19 +0600 Subject: [PATCH 065/138] api/vk: add support for subtitles --- api/src/processing/match-action.js | 2 +- api/src/processing/match.js | 3 ++- api/src/processing/services/vk.js | 14 +++++++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index b5c70409..c17158f7 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -169,6 +169,7 @@ export default function({ } break; + case "vk": case "tiktok": params = { type: r.subtitles ? "remux" : "proxy" @@ -176,7 +177,6 @@ export default function({ break; case "ok": - case "vk": case "xiaohongshu": params = { type: "proxy" }; break; diff --git a/api/src/processing/match.js b/api/src/processing/match.js index 90e60186..fb0c9588 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -92,7 +92,8 @@ export default async function({ host, patternMatch, params, isSession, isApiKey ownerId: patternMatch.ownerId, videoId: patternMatch.videoId, accessKey: patternMatch.accessKey, - quality: params.videoQuality + quality: params.videoQuality, + subtitleLang, }); break; diff --git a/api/src/processing/services/vk.js b/api/src/processing/services/vk.js index 33224d69..f871588a 100644 --- a/api/src/processing/services/vk.js +++ b/api/src/processing/services/vk.js @@ -76,7 +76,7 @@ const getVideo = async (ownerId, videoId, accessKey) => { return video; } -export default async function ({ ownerId, videoId, accessKey, quality }) { +export default async function ({ ownerId, videoId, accessKey, quality, subtitleLang }) { const token = await getToken(); if (!token) return { error: "fetch.fail" }; @@ -125,8 +125,20 @@ export default async function ({ ownerId, videoId, accessKey, quality }) { title: video.title.trim(), } + let subtitles; + if (subtitleLang && video.subtitles?.length) { + const subtitle = video.subtitles.find( + s => !s.is_auto && s.lang.startsWith(subtitleLang) + ); + if (subtitle) { + subtitles = subtitle.url; + fileMetadata.sublanguage = subtitleLang; + } + } + return { urls: url, + subtitles, fileMetadata, filenameAttributes: { service: "vk", From ff06a10b5cbd6cb4fb88f3dc338f9dc51f8614d0 Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 24 Jun 2025 17:21:32 +0600 Subject: [PATCH 066/138] api/processing/url: improve vk url parsing --- api/src/processing/service-config.js | 9 +++++---- api/src/processing/url.js | 15 +++++++++------ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/api/src/processing/service-config.js b/api/src/processing/service-config.js index 1c77c7bb..662b39b3 100644 --- a/api/src/processing/service-config.js +++ b/api/src/processing/service-config.js @@ -186,12 +186,13 @@ export const services = { patterns: [ "video:ownerId_:videoId", "clip:ownerId_:videoId", - "clips:duplicate?z=clip:ownerId_:videoId", - "videos:duplicate?z=video:ownerId_:videoId", "video:ownerId_:videoId_:accessKey", "clip:ownerId_:videoId_:accessKey", - "clips:duplicate?z=clip:ownerId_:videoId_:accessKey", - "videos:duplicate?z=video:ownerId_:videoId_:accessKey" + + // links with a duplicate author id and/or zipper query param + "clips:duplicateId", + "videos:duplicateId", + "search/video" ], subdomains: ["m"], altDomains: ["vkvideo.ru", "vk.ru"], diff --git a/api/src/processing/url.js b/api/src/processing/url.js index 86c333f6..5bb690e6 100644 --- a/api/src/processing/url.js +++ b/api/src/processing/url.js @@ -17,7 +17,7 @@ function aliasURL(url) { if (url.pathname.startsWith('/live/') || url.pathname.startsWith('/shorts/')) { url.pathname = '/watch'; // parts := ['', 'live' || 'shorts', id, ...rest] - url.search = `?v=${encodeURIComponent(parts[2])}` + url.search = `?v=${encodeURIComponent(parts[2])}`; } break; @@ -61,23 +61,23 @@ function aliasURL(url) { case "b23": if (url.hostname === 'b23.tv' && parts.length === 2) { - url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`) + url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`); } break; case "dai": if (url.hostname === 'dai.ly' && parts.length === 2) { - url = new URL(`https://dailymotion.com/video/${parts[1]}`) + url = new URL(`https://dailymotion.com/video/${parts[1]}`); } break; case "facebook": case "fb": if (url.searchParams.get('v')) { - url = new URL(`https://web.facebook.com/user/videos/${url.searchParams.get('v')}`) + url = new URL(`https://web.facebook.com/user/videos/${url.searchParams.get('v')}`); } if (url.hostname === 'fb.watch') { - url = new URL(`https://web.facebook.com/_shortLink/${parts[1]}`) + url = new URL(`https://web.facebook.com/_shortLink/${parts[1]}`); } break; @@ -92,6 +92,9 @@ function aliasURL(url) { if (services.vk.altDomains.includes(url.hostname)) { url.hostname = 'vk.com'; } + if (url.searchParams.get('z')) { + url = new URL(`https://vk.com/${url.searchParams.get('z')}`); + } break; case "xhslink": @@ -106,7 +109,7 @@ function aliasURL(url) { url.pathname = `/share/${idPart.slice(-32)}`; } break; - + case "redd": /* reddit short video links can be treated by changing https://v.redd.it/ to https://reddit.com/video/.*/ From 75691d4bacea86777ab7e00ef0d63beb7a73394b Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 24 Jun 2025 17:28:05 +0600 Subject: [PATCH 067/138] api/tests/facebook: replace a dead link --- api/src/util/tests/facebook.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/util/tests/facebook.json b/api/src/util/tests/facebook.json index 70e2db68..a8b9607e 100644 --- a/api/src/util/tests/facebook.json +++ b/api/src/util/tests/facebook.json @@ -1,7 +1,7 @@ [ { "name": "direct video with username and id", - "url": "https://web.facebook.com/100048111287134/videos/1157798148685638/", + "url": "https://web.facebook.com/100071784061914/videos/588631943886661/", "params": {}, "expected": { "code": 200, From 28b85380c92bfa573b09a92a13942b6597db825e Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 24 Jun 2025 17:56:04 +0600 Subject: [PATCH 068/138] api/vk: allow auto generated subs & pick explicitly vtt i couldn't find a single video that had any subtitles other than auto generated ones, so i think this is better than nothing at all --- api/src/processing/services/vk.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/processing/services/vk.js b/api/src/processing/services/vk.js index f871588a..c07d964a 100644 --- a/api/src/processing/services/vk.js +++ b/api/src/processing/services/vk.js @@ -128,7 +128,7 @@ export default async function ({ ownerId, videoId, accessKey, quality, subtitleL let subtitles; if (subtitleLang && video.subtitles?.length) { const subtitle = video.subtitles.find( - s => !s.is_auto && s.lang.startsWith(subtitleLang) + s => s.title.endsWith(".vtt") && s.lang.startsWith(subtitleLang) ); if (subtitle) { subtitles = subtitle.url; From aa376d76f6649982d360f0b173d073a9d731dbc5 Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 24 Jun 2025 19:55:50 +0600 Subject: [PATCH 069/138] api/stream/types: huge refactor & simplification of code - created render() which handles ffmpeg & piping stuff - merged remux() and merge() into one function - simplified and cleaned up arguments - removed headers since they're handled by internal streams now - removed outdated arguments --- api/src/stream/stream.js | 2 - api/src/stream/types.js | 326 ++++++++++++--------------------------- 2 files changed, 98 insertions(+), 230 deletions(-) diff --git a/api/src/stream/stream.js b/api/src/stream/stream.js index e714f38e..3050c08b 100644 --- a/api/src/stream/stream.js +++ b/api/src/stream/stream.js @@ -13,8 +13,6 @@ export default async function(res, streamInfo) { return await internalStream(streamInfo.data, res); case "merge": - return await stream.merge(streamInfo, res); - case "remux": case "mute": return await stream.remux(streamInfo, res); diff --git a/api/src/stream/types.js b/api/src/stream/types.js index 7b5a5774..746557bc 100644 --- a/api/src/stream/types.js +++ b/api/src/stream/types.js @@ -22,7 +22,7 @@ const metadataTags = [ ]; const convertMetadataToFFmpeg = (metadata) => { - let args = []; + const args = []; for (const [ name, value ] of Object.entries(metadata)) { if (metadataTags.includes(name)) { @@ -39,12 +39,6 @@ const convertMetadataToFFmpeg = (metadata) => { return args; } -const toRawHeaders = (headers) => { - return Object.entries(headers) - .map(([key, value]) => `${key}: ${value}\r\n`) - .join(''); -} - const killProcess = (p) => { p?.kill('SIGTERM'); // ask the process to terminate itself gracefully @@ -99,64 +93,27 @@ const proxy = async (streamInfo, res) => { } } -const merge = async (streamInfo, res) => { +const render = async (res, streamInfo, ffargs, multiplier) => { let process; + const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls]; const shutdown = () => ( killProcess(process), closeResponse(res), - streamInfo.urls.map(destroyInternalStream) + urls.map(destroyInternalStream) ); try { - if (streamInfo.urls.length !== 2) return shutdown(); + // if the streamInfo.urls is an array but doesn't have 2 urls, + // then something went wrong + if (Array.isArray(streamInfo.urls) && streamInfo.urls.length !== 2) { + return shutdown(); + } - const format = streamInfo.filename.split('.').pop(); - - let args = [ + const args = [ '-loglevel', '-8', - '-i', streamInfo.urls[0], - '-i', streamInfo.urls[1], + ...ffargs, ]; - if (streamInfo.subtitles) { - args.push( - '-i', streamInfo.subtitles, - '-map', '2:s', - '-c:s', format === 'mp4' ? 'mov_text' : 'webvtt', - ); - }; - - args.push( - '-map', '0:v', - '-map', '1:a', - '-c:v', 'copy', - '-c:a', 'copy', - ); - - if (format === "mp4") { - args.push( - '-movflags', - 'faststart+frag_keyframe+empty_moov', - ) - } - - if (hlsExceptions.includes(streamInfo.service) && streamInfo.isHLS) { - if (streamInfo.service === "youtube" && format === "webm") { - args.push('-c:a', 'libopus'); - } else { - args.push('-c:a', 'aac', '-bsf:a', 'aac_adtstoasc'); - } - } - - if (streamInfo.metadata) { - args = args.concat(convertMetadataToFFmpeg(streamInfo.metadata)); - } - - args.push( - '-f', format === "mkv" ? "matroska" : format, - 'pipe:3' - ); - process = spawn(...getCommand(args), { windowsHide: true, stdio: [ @@ -169,7 +126,7 @@ const merge = async (streamInfo, res) => { res.setHeader('Connection', 'keep-alive'); res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); - res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo)); + res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo, multiplier)); pipe(muxOutput, res, shutdown); @@ -181,201 +138,114 @@ const merge = async (streamInfo, res) => { } const remux = async (streamInfo, res) => { - let process; - const shutdown = () => ( - killProcess(process), - closeResponse(res), - destroyInternalStream(streamInfo.urls) - ); + const format = streamInfo.filename.split('.').pop(); + const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls]; + const args = [...urls.flatMap(url => ['-i', url])]; - try { - const format = streamInfo.filename.split('.').pop(); - - let args = [ - '-loglevel', '-8', - '-headers', toRawHeaders(getHeaders(streamInfo.service)), - ] - - if (streamInfo.service === "twitter") { - args.push('-seekable', '0') - } - - args.push('-i', streamInfo.urls); - - if (streamInfo.subtitles) { - args.push( - '-i', streamInfo.subtitles, - '-c:s', format === 'mp4' ? 'mov_text' : 'webvtt', - ); - }; + if (streamInfo.subtitles) { + args.push( + '-i', streamInfo.subtitles, + '-map', `${urls.length}:s`, + '-c:s', format === 'mp4' ? 'mov_text' : 'webvtt', + ); + } + if (urls.length === 2) { + args.push( + '-map', '0:v', + '-map', '1:a', + '-c:v', 'copy', + '-c:a', 'copy' + ); + } else { args.push('-c:v', 'copy'); - if (streamInfo.type === "mute") { + if (streamInfo.type === 'mute') { args.push('-an'); + } else { + args.push('-c:a', 'copy'); } - - if (hlsExceptions.includes(streamInfo.service)) { - if (streamInfo.type !== "mute") { - args.push('-c:a', 'aac'); - } - args.push('-bsf:a', 'aac_adtstoasc'); - } - - if (format === "mp4") { - args.push('-movflags', 'faststart+frag_keyframe+empty_moov') - } - - if (streamInfo.metadata) { - args = args.concat(convertMetadataToFFmpeg(streamInfo.metadata)); - } - - args.push('-f', format, 'pipe:3'); - - process = spawn(...getCommand(args), { - windowsHide: true, - stdio: [ - 'inherit', 'inherit', 'inherit', - 'pipe' - ], - }); - - const [,,, muxOutput] = process.stdio; - - res.setHeader('Connection', 'keep-alive'); - res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); - res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo)); - - pipe(muxOutput, res, shutdown); - - process.on('close', shutdown); - res.on('finish', shutdown); - } catch { - shutdown(); } + + if (format === 'mp4') { + args.push('-movflags', 'faststart+frag_keyframe+empty_moov'); + } + + if (streamInfo.type !== 'mute' && streamInfo.isHLS && hlsExceptions.includes(streamInfo.service)) { + if (streamInfo.service === 'youtube' && format === 'webm') { + args.push('-c:a', 'libopus'); + } else { + args.push('-c:a', 'aac', '-bsf:a', 'aac_adtstoasc'); + } + } + + if (streamInfo.metadata) { + args.push(...convertMetadataToFFmpeg(streamInfo.metadata)); + } + + args.push('-f', format === 'mkv' ? 'matroska' : format, 'pipe:3'); + + await render(res, streamInfo, args); } const convertAudio = async (streamInfo, res) => { - let process; - const shutdown = () => ( - killProcess(process), - closeResponse(res), - destroyInternalStream(streamInfo.urls) + const args = [ + '-i', streamInfo.urls, + '-vn', + ...(streamInfo.audioCopy ? ['-c:a', 'copy'] : ['-b:a', `${streamInfo.audioBitrate}k`]), + ]; + + if (streamInfo.audioFormat === 'mp3' && streamInfo.audioBitrate === '8') { + args.push('-ar', '12000'); + } + + if (streamInfo.audioFormat === 'opus') { + args.push('-vbr', 'off'); + } + + if (streamInfo.audioFormat === 'mp4a') { + args.push('-movflags', 'frag_keyframe+empty_moov'); + } + + if (streamInfo.metadata) { + args.push(...convertMetadataToFFmpeg(streamInfo.metadata)); + } + + args.push( + '-f', + streamInfo.audioFormat === 'm4a' ? 'ipod' : streamInfo.audioFormat, + 'pipe:3' ); - try { - let args = [ - '-loglevel', '-8', - '-headers', toRawHeaders(getHeaders(streamInfo.service)), - ] - - if (streamInfo.service === "twitter") { - args.push('-seekable', '0'); - } - - args.push( - '-i', streamInfo.urls, - '-vn' - ) - - if (streamInfo.audioCopy) { - args.push("-c:a", "copy") - } else { - args.push("-b:a", `${streamInfo.audioBitrate}k`) - } - - if (streamInfo.audioFormat === "mp3" && streamInfo.audioBitrate === "8") { - args.push("-ar", "12000"); - } - - if (streamInfo.audioFormat === "opus") { - args.push("-vbr", "off"); - } - - if (streamInfo.audioFormat === "mp4a") { - args.push("-movflags", "frag_keyframe+empty_moov"); - } - - if (streamInfo.metadata) { - args = args.concat(convertMetadataToFFmpeg(streamInfo.metadata)) - } - - args.push('-f', streamInfo.audioFormat === "m4a" ? "ipod" : streamInfo.audioFormat, 'pipe:3'); - - process = spawn(...getCommand(args), { - windowsHide: true, - stdio: [ - 'inherit', 'inherit', 'inherit', - 'pipe' - ], - }); - - const [,,, muxOutput] = process.stdio; - - res.setHeader('Connection', 'keep-alive'); - res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); - res.setHeader( - 'Estimated-Content-Length', - await estimateTunnelLength( - streamInfo, - estimateAudioMultiplier(streamInfo) * 1.1 - ) - ); - - pipe(muxOutput, res, shutdown); - res.on('finish', shutdown); - } catch { - shutdown(); - } + await render( + res, + streamInfo, + args, + estimateAudioMultiplier(streamInfo) * 1.1, + ); } const convertGif = async (streamInfo, res) => { - let process; - const shutdown = () => (killProcess(process), closeResponse(res)); + const args = [ + '-i', streamInfo.urls, - try { - let args = [ - '-loglevel', '-8' - ] + '-vf', + 'scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse', + '-loop', '0', - if (streamInfo.service === "twitter") { - args.push('-seekable', '0') - } + '-f', "gif", 'pipe:3', + ]; - args.push('-i', streamInfo.urls); - args.push( - '-vf', - 'scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse', - '-loop', '0' - ); - args.push('-f', "gif", 'pipe:3'); - - process = spawn(...getCommand(args), { - windowsHide: true, - stdio: [ - 'inherit', 'inherit', 'inherit', - 'pipe' - ], - }); - - const [,,, muxOutput] = process.stdio; - - res.setHeader('Connection', 'keep-alive'); - res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); - res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo, 60)); - - pipe(muxOutput, res, shutdown); - - process.on('close', shutdown); - res.on('finish', shutdown); - } catch { - shutdown(); - } + await render( + res, + streamInfo, + args, + 60, + ); } export default { proxy, - merge, remux, convertAudio, convertGif, From 14657e51d3d185f91aa97cd6a67391f5534ccb96 Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 24 Jun 2025 20:09:41 +0600 Subject: [PATCH 070/138] api/stream: split types.js into proxy.js and ffmpeg.js --- api/src/stream/{types.js => ffmpeg.js} | 42 +------------------------ api/src/stream/proxy.js | 43 ++++++++++++++++++++++++++ api/src/stream/stream.js | 11 ++++--- 3 files changed, 50 insertions(+), 46 deletions(-) rename api/src/stream/{types.js => ffmpeg.js} (81%) create mode 100644 api/src/stream/proxy.js diff --git a/api/src/stream/types.js b/api/src/stream/ffmpeg.js similarity index 81% rename from api/src/stream/types.js rename to api/src/stream/ffmpeg.js index 746557bc..d20525c3 100644 --- a/api/src/stream/types.js +++ b/api/src/stream/ffmpeg.js @@ -1,12 +1,11 @@ import ffmpeg from "ffmpeg-static"; import { spawn } from "child_process"; -import { Agent, request } from "undici"; import { create as contentDisposition } from "content-disposition-header"; import { env } from "../config.js"; import { destroyInternalStream } from "./manage.js"; import { hlsExceptions } from "../processing/service-config.js"; -import { getHeaders, closeRequest, closeResponse, pipe, estimateTunnelLength, estimateAudioMultiplier } from "./shared.js"; +import { closeResponse, pipe, estimateTunnelLength, estimateAudioMultiplier } from "./shared.js"; const metadataTags = [ "album", @@ -55,44 +54,6 @@ const getCommand = (args) => { return [ffmpeg, args] } -const defaultAgent = new Agent(); - -const proxy = async (streamInfo, res) => { - const abortController = new AbortController(); - const shutdown = () => ( - closeRequest(abortController), - closeResponse(res), - destroyInternalStream(streamInfo.urls) - ); - - try { - res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); - res.setHeader('Content-disposition', contentDisposition(streamInfo.filename)); - - const { body: stream, headers, statusCode } = await request(streamInfo.urls, { - headers: { - ...getHeaders(streamInfo.service), - Range: streamInfo.range - }, - signal: abortController.signal, - maxRedirections: 16, - dispatcher: defaultAgent, - }); - - res.status(statusCode); - - for (const headerName of ['accept-ranges', 'content-type', 'content-length']) { - if (headers[headerName]) { - res.setHeader(headerName, headers[headerName]); - } - } - - pipe(stream, res, shutdown); - } catch { - shutdown(); - } -} - const render = async (res, streamInfo, ffargs, multiplier) => { let process; const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls]; @@ -245,7 +206,6 @@ const convertGif = async (streamInfo, res) => { } export default { - proxy, remux, convertAudio, convertGif, diff --git a/api/src/stream/proxy.js b/api/src/stream/proxy.js new file mode 100644 index 00000000..d51927a9 --- /dev/null +++ b/api/src/stream/proxy.js @@ -0,0 +1,43 @@ +import { Agent, request } from "undici"; +import { create as contentDisposition } from "content-disposition-header"; + +import { destroyInternalStream } from "./manage.js"; +import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js"; + +const defaultAgent = new Agent(); + +export default async function (streamInfo, res) { + const abortController = new AbortController(); + const shutdown = () => ( + closeRequest(abortController), + closeResponse(res), + destroyInternalStream(streamInfo.urls) + ); + + try { + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + res.setHeader('Content-disposition', contentDisposition(streamInfo.filename)); + + const { body: stream, headers, statusCode } = await request(streamInfo.urls, { + headers: { + ...getHeaders(streamInfo.service), + Range: streamInfo.range + }, + signal: abortController.signal, + maxRedirections: 16, + dispatcher: defaultAgent, + }); + + res.status(statusCode); + + for (const headerName of ['accept-ranges', 'content-type', 'content-length']) { + if (headers[headerName]) { + res.setHeader(headerName, headers[headerName]); + } + } + + pipe(stream, res, shutdown); + } catch { + shutdown(); + } +} diff --git a/api/src/stream/stream.js b/api/src/stream/stream.js index 3050c08b..1290c029 100644 --- a/api/src/stream/stream.js +++ b/api/src/stream/stream.js @@ -1,4 +1,5 @@ -import stream from "./types.js"; +import proxy from "./proxy.js"; +import ffmpeg from "./ffmpeg.js"; import { closeResponse } from "./shared.js"; import { internalStream } from "./internal.js"; @@ -7,7 +8,7 @@ export default async function(res, streamInfo) { try { switch (streamInfo.type) { case "proxy": - return await stream.proxy(streamInfo, res); + return await proxy(streamInfo, res); case "internal": return await internalStream(streamInfo.data, res); @@ -15,13 +16,13 @@ export default async function(res, streamInfo) { case "merge": case "remux": case "mute": - return await stream.remux(streamInfo, res); + return await ffmpeg.remux(streamInfo, res); case "audio": - return await stream.convertAudio(streamInfo, res); + return await ffmpeg.convertAudio(streamInfo, res); case "gif": - return await stream.convertGif(streamInfo, res); + return await ffmpeg.convertGif(streamInfo, res); } closeResponse(res); From 4f4478a21dcf2c3891a7da490c63e377d0a88ca4 Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 24 Jun 2025 20:24:53 +0600 Subject: [PATCH 071/138] api/ffmpeg: fix audio codec args in remux() --- api/src/stream/ffmpeg.js | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/api/src/stream/ffmpeg.js b/api/src/stream/ffmpeg.js index d20525c3..d8e69676 100644 --- a/api/src/stream/ffmpeg.js +++ b/api/src/stream/ffmpeg.js @@ -29,7 +29,7 @@ const convertMetadataToFFmpeg = (metadata) => { args.push('-metadata:s:s:0', `language=${value}`); continue; } - args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`); // skipcq: JS-0004 + args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, '')}`); // skipcq: JS-0004 } else { throw `${name} metadata tag is not supported.`; } @@ -116,18 +116,15 @@ const remux = async (streamInfo, res) => { '-map', '0:v', '-map', '1:a', '-c:v', 'copy', - '-c:a', 'copy' ); } else { args.push('-c:v', 'copy'); - - if (streamInfo.type === 'mute') { - args.push('-an'); - } else { - args.push('-c:a', 'copy'); - } } + args.push( + ...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy']) + ); + if (format === 'mp4') { args.push('-movflags', 'faststart+frag_keyframe+empty_moov'); } @@ -175,7 +172,7 @@ const convertAudio = async (streamInfo, res) => { args.push( '-f', streamInfo.audioFormat === 'm4a' ? 'ipod' : streamInfo.audioFormat, - 'pipe:3' + 'pipe:3', ); await render( @@ -194,7 +191,7 @@ const convertGif = async (streamInfo, res) => { 'scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse', '-loop', '0', - '-f', "gif", 'pipe:3', + '-f', 'gif', 'pipe:3', ]; await render( From d3793c7a548432f05bd8fffba1892ffa957428c6 Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 24 Jun 2025 20:46:14 +0600 Subject: [PATCH 072/138] api/ffmpeg: map video and audio in remux() with one main input cuz otherwise if a video has subtitles, then only subtitles get mapped to the output file --- api/src/stream/ffmpeg.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/src/stream/ffmpeg.js b/api/src/stream/ffmpeg.js index d8e69676..3e05d072 100644 --- a/api/src/stream/ffmpeg.js +++ b/api/src/stream/ffmpeg.js @@ -118,6 +118,8 @@ const remux = async (streamInfo, res) => { '-c:v', 'copy', ); } else { + args.push('-map', '0:v:0'); + args.push('-map', '0:a:0'); args.push('-c:v', 'copy'); } From fcdf5da73e3de862f0589a44c855ebca0cab4cd8 Mon Sep 17 00:00:00 2001 From: wukko Date: Wed, 25 Jun 2025 19:32:36 +0600 Subject: [PATCH 073/138] api/ffmpeg: refactor even more --- api/src/stream/ffmpeg.js | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/api/src/stream/ffmpeg.js b/api/src/stream/ffmpeg.js index 3e05d072..201804da 100644 --- a/api/src/stream/ffmpeg.js +++ b/api/src/stream/ffmpeg.js @@ -7,7 +7,7 @@ import { destroyInternalStream } from "./manage.js"; import { hlsExceptions } from "../processing/service-config.js"; import { closeResponse, pipe, estimateTunnelLength, estimateAudioMultiplier } from "./shared.js"; -const metadataTags = [ +const metadataTags = new Set([ "album", "composer", "genre", @@ -18,13 +18,13 @@ const metadataTags = [ "track", "date", "sublanguage" -]; +]); const convertMetadataToFFmpeg = (metadata) => { const args = []; for (const [ name, value ] of Object.entries(metadata)) { - if (metadataTags.includes(name)) { + if (metadataTags.has(name)) { if (name === "sublanguage") { args.push('-metadata:s:s:0', `language=${value}`); continue; @@ -54,7 +54,7 @@ const getCommand = (args) => { return [ffmpeg, args] } -const render = async (res, streamInfo, ffargs, multiplier) => { +const render = async (res, streamInfo, ffargs, estimateMultiplier) => { let process; const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls]; const shutdown = () => ( @@ -66,7 +66,7 @@ const render = async (res, streamInfo, ffargs, multiplier) => { try { // if the streamInfo.urls is an array but doesn't have 2 urls, // then something went wrong - if (Array.isArray(streamInfo.urls) && streamInfo.urls.length !== 2) { + if (urls.length !== 2) { return shutdown(); } @@ -87,7 +87,11 @@ const render = async (res, streamInfo, ffargs, multiplier) => { res.setHeader('Connection', 'keep-alive'); res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); - res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo, multiplier)); + + res.setHeader( + 'Estimated-Content-Length', + await estimateTunnelLength(streamInfo, estimateMultiplier) + ); pipe(muxOutput, res, shutdown); @@ -101,7 +105,7 @@ const render = async (res, streamInfo, ffargs, multiplier) => { const remux = async (streamInfo, res) => { const format = streamInfo.filename.split('.').pop(); const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls]; - const args = [...urls.flatMap(url => ['-i', url])]; + const args = urls.flatMap(url => ['-i', url]); if (streamInfo.subtitles) { args.push( @@ -115,15 +119,16 @@ const remux = async (streamInfo, res) => { args.push( '-map', '0:v', '-map', '1:a', - '-c:v', 'copy', ); } else { - args.push('-map', '0:v:0'); - args.push('-map', '0:a:0'); - args.push('-c:v', 'copy'); + args.push( + '-map', '0:v:0', + '-map', '0:a:0' + ); } args.push( + '-c:v', 'copy', ...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy']) ); From 52695cbd0f2f8794b7d04ae43e570e81c63c6843 Mon Sep 17 00:00:00 2001 From: wukko Date: Wed, 25 Jun 2025 19:33:16 +0600 Subject: [PATCH 074/138] api/service-config: replace static arrays with sets --- api/src/processing/match-action.js | 2 +- api/src/processing/service-config.js | 4 ++-- api/src/stream/ffmpeg.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index c17158f7..e5e5a1a9 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -194,7 +194,7 @@ export default function({ break; case "audio": - if (audioIgnore.includes(host) || (host === "reddit" && r.typeId === "redirect")) { + if (audioIgnore.has(host) || (host === "reddit" && r.typeId === "redirect")) { return createResponse("error", { code: "error.api.service.audio_not_supported" }) diff --git a/api/src/processing/service-config.js b/api/src/processing/service-config.js index 662b39b3..3ffcf10a 100644 --- a/api/src/processing/service-config.js +++ b/api/src/processing/service-config.js @@ -1,7 +1,7 @@ import UrlPattern from "url-pattern"; -export const audioIgnore = ["vk", "ok", "loom"]; -export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky", "youtube"]; +export const audioIgnore = new Set(["vk", "ok", "loom"]); +export const hlsExceptions = new Set(["dailymotion", "vimeo", "rutube", "bsky", "youtube"]); export const services = { bilibili: { diff --git a/api/src/stream/ffmpeg.js b/api/src/stream/ffmpeg.js index 201804da..dff4abab 100644 --- a/api/src/stream/ffmpeg.js +++ b/api/src/stream/ffmpeg.js @@ -136,7 +136,7 @@ const remux = async (streamInfo, res) => { args.push('-movflags', 'faststart+frag_keyframe+empty_moov'); } - if (streamInfo.type !== 'mute' && streamInfo.isHLS && hlsExceptions.includes(streamInfo.service)) { + if (streamInfo.type !== 'mute' && streamInfo.isHLS && hlsExceptions.has(streamInfo.service)) { if (streamInfo.service === 'youtube' && format === 'webm') { args.push('-c:a', 'libopus'); } else { From 3dae5b2eb017b684e06c436cea1bac9048e8faea Mon Sep 17 00:00:00 2001 From: wukko Date: Wed, 25 Jun 2025 19:57:23 +0600 Subject: [PATCH 075/138] api/ffmpeg: move stream type + url count check to remux() & fix it cuz i broke it in last commit --- api/src/stream/ffmpeg.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/api/src/stream/ffmpeg.js b/api/src/stream/ffmpeg.js index dff4abab..4a72a309 100644 --- a/api/src/stream/ffmpeg.js +++ b/api/src/stream/ffmpeg.js @@ -64,12 +64,6 @@ const render = async (res, streamInfo, ffargs, estimateMultiplier) => { ); try { - // if the streamInfo.urls is an array but doesn't have 2 urls, - // then something went wrong - if (urls.length !== 2) { - return shutdown(); - } - const args = [ '-loglevel', '-8', ...ffargs, @@ -107,6 +101,11 @@ const remux = async (streamInfo, res) => { const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls]; const args = urls.flatMap(url => ['-i', url]); + // if the stream type is merge, we expect two URLs + if (streamInfo.type === 'merge' && urls.length !== 2) { + return closeResponse(res); + } + if (streamInfo.subtitles) { args.push( '-i', streamInfo.subtitles, From f4637b746cd9044f4d9d1bb42912c9f9b8f00c29 Mon Sep 17 00:00:00 2001 From: wukko Date: Wed, 25 Jun 2025 20:07:34 +0600 Subject: [PATCH 076/138] api/rutube: add subtitles --- api/src/processing/match.js | 1 + api/src/processing/services/rutube.js | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/api/src/processing/match.js b/api/src/processing/match.js index fb0c9588..1e493be6 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -221,6 +221,7 @@ export default async function({ host, patternMatch, params, isSession, isApiKey key: patternMatch.key, quality: params.videoQuality, isAudioOnly, + subtitleLang, }); break; diff --git a/api/src/processing/services/rutube.js b/api/src/processing/services/rutube.js index 5b502452..9fe37d34 100644 --- a/api/src/processing/services/rutube.js +++ b/api/src/processing/services/rutube.js @@ -65,8 +65,21 @@ export default async function(obj) { artist: play.author.name.trim(), } + let subtitles; + if (obj.subtitleLang && play.captions?.length) { + const subtitle = play.captions.find( + s => ["webvtt", "srt"].includes(s.format) && s.code.startsWith(obj.subtitleLang) + ); + + if (subtitle) { + subtitles = subtitle.file; + fileMetadata.sublanguage = obj.subtitleLang; + } + } + return { urls: matchingQuality.uri, + subtitles, isHLS: true, filenameAttributes: { service: "rutube", From f7e5951410d447af9b3f191acfe39cddd356deea Mon Sep 17 00:00:00 2001 From: wukko Date: Wed, 25 Jun 2025 23:19:24 +0600 Subject: [PATCH 077/138] web/lib/device: enable local processing on all ios devices --- web/src/lib/device.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/device.ts b/web/src/lib/device.ts index 9b931713..a9686024 100644 --- a/web/src/lib/device.ts +++ b/web/src/lib/device.ts @@ -84,8 +84,8 @@ if (browser) { haptics: modernIOS, // enable local processing by default on - // desktop, ios 18+, and firefox on android - defaultLocalProcessing: !device.is.mobile || modernIOS || + // desktop, ios, and firefox on android + defaultLocalProcessing: !device.is.mobile || iOS || (device.is.android && !device.browser.chrome), }; From 4ff4766bda3557d709ffcf09e58240de119ee566 Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 26 Jun 2025 15:59:38 +0600 Subject: [PATCH 078/138] docs/api: add info about subtitle bool in local processing response --- docs/api.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/api.md b/docs/api.md index cad02519..dfeea7b8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -121,11 +121,12 @@ the response will always be a JSON object containing the `status` key, which is | `isHLS` | `boolean` | whether the output is in HLS format (optional) | #### output object -| key | type | value | -|:-----------|:---------|:----------------------------------------------------------------------------------| -| `type` | `string` | mime type of the output file | -| `filename` | `string` | filename of the output file | -| `metadata` | `object` | metadata associated with the file (optional, [see below](#outputmetadata-object)) | +| key | type | value | +|:------------|:----------|:----------------------------------------------------------------------------------| +| `type` | `string` | mime type of the output file | +| `filename` | `string` | filename of the output file | +| `metadata` | `object` | metadata associated with the file (optional, [see below](#outputmetadata-object)) | +| `subtitles` | `boolean` | whether tunnels include a subtitle file | #### output.metadata object all keys in this table are optional. From 164ea8aeb93a4b710da1718cbc02878797b2f438 Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 26 Jun 2025 17:36:26 +0600 Subject: [PATCH 079/138] api: return covers from soundcloud and youtube & refactor createProxyTunnels() in stream/manage a little --- api/src/processing/match-action.js | 2 ++ api/src/processing/request.js | 2 ++ api/src/processing/services/soundcloud.js | 6 +++++ api/src/processing/services/youtube.js | 14 +++++++++++- api/src/stream/manage.js | 28 +++++++++++++++-------- 5 files changed, 42 insertions(+), 10 deletions(-) diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index e5e5a1a9..555ecc33 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -34,6 +34,8 @@ export default function({ requestIP, originalRequest: r.originalRequest, subtitles: r.subtitles, + cover: r.cover, + cropCover: r.cropCover, }, params = {}; diff --git a/api/src/processing/request.js b/api/src/processing/request.js index 62a4d781..b9ebca05 100644 --- a/api/src/processing/request.js +++ b/api/src/processing/request.js @@ -67,6 +67,8 @@ export function createResponse(responseType, responseData) { copy: responseData?.audioCopy, format: responseData?.audioFormat, bitrate: responseData?.audioBitrate, + cover: !!responseData?.cover || undefined, + cropCover: !!responseData?.cropCover || undefined, }, isHLS: responseData?.isHLS, diff --git a/api/src/processing/services/soundcloud.js b/api/src/processing/services/soundcloud.js index 046ebd79..1c04a366 100644 --- a/api/src/processing/services/soundcloud.js +++ b/api/src/processing/services/soundcloud.js @@ -146,8 +146,14 @@ export default async function(obj) { copyright: json.license?.trim(), } + let cover; + if (json.artwork_url) { + cover = json.artwork_url.replace(/-large/, "-t1080x1080"); + } + return { urls: file.toString(), + cover, filenameAttributes: { service: "soundcloud", id: json.id, diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js index f05e3086..55caa835 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -532,6 +532,15 @@ export default async function (o) { urls = audio.decipher(innertube.session.player); } + let cover = `https://i.ytimg.com/vi/${o.id}/maxresdefault.jpg`; + const testMaxCover = await fetch(cover, { dispatcher: o.dispatcher }) + .then(r => r.status === 200) + .catch(() => {}); + + if (!testMaxCover) { + cover = basicInfo.thumbnail?.[0]?.url; + } + return { type: "audio", isAudioOnly: true, @@ -540,7 +549,10 @@ export default async function (o) { fileMetadata, bestAudio, isHLS: useHLS, - originalRequest + originalRequest, + + cover, + cropCover: basicInfo.author.endsWith("- Topic"), } } diff --git a/api/src/stream/manage.js b/api/src/stream/manage.js index ee0477e6..93e9e652 100644 --- a/api/src/stream/manage.js +++ b/api/src/stream/manage.js @@ -82,17 +82,19 @@ export function createProxyTunnels(info) { urls = [urls]; } + const tunnelTemplate = { + type: "proxy", + headers: info?.headers, + requestIP: info?.requestIP, + } + for (const url of urls) { proxyTunnels.push( createStream({ + ...tunnelTemplate, url, - type: "proxy", - service: info?.service, - headers: info?.headers, - requestIP: info?.requestIP, - - originalRequest: info?.originalRequest + originalRequest: info?.originalRequest, }) ); } @@ -100,11 +102,19 @@ export function createProxyTunnels(info) { if (info.subtitles) { proxyTunnels.push( createStream({ + ...tunnelTemplate, url: info.subtitles, - type: "proxy", service: `${info?.service}-subtitles`, - headers: info?.headers, - requestIP: info?.requestIP + }) + ); + } + + if (info.cover) { + proxyTunnels.push( + createStream({ + ...tunnelTemplate, + url: info.cover, + service: `${info?.service}-cover`, }) ); } From e4ce873b565f9b6e8143fa819a687790c8d2a935 Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 26 Jun 2025 17:36:55 +0600 Subject: [PATCH 080/138] web/queue: add audio covers & crop them when needed --- web/src/lib/task-manager/queue.ts | 22 +++++++++++++++++++--- web/src/lib/types/api.ts | 2 ++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/web/src/lib/task-manager/queue.ts b/web/src/lib/task-manager/queue.ts index 7b507426..73ced4d4 100644 --- a/web/src/lib/task-manager/queue.ts +++ b/web/src/lib/task-manager/queue.ts @@ -82,11 +82,27 @@ const makeAudioArgs = (info: CobaltLocalProcessingResponse) => { return; } - const ffargs = [ - "-vn", + const ffargs = []; + + if (info.audio.cover) { + ffargs.push( + "-map", "0", + "-map", "1", + ...(info.audio.cropCover ? [ + "-c:v", "mjpeg", + "-vf", "scale=-1:800,crop=800:800", + ] : [ + "-c:v", "copy", + ]), + ); + } else { + ffargs.push("-vn"); + } + + ffargs.push( ...(info.audio.copy ? ["-c:a", "copy"] : ["-b:a", `${info.audio.bitrate}k`]), ...(info.output.metadata ? ffmpegMetadataArgs(info.output.metadata) : []) - ]; + ); if (info.audio.format === "mp3" && info.audio.bitrate === "8") { ffargs.push("-ar", "12000"); diff --git a/web/src/lib/types/api.ts b/web/src/lib/types/api.ts index 2091a8e2..f127d02e 100644 --- a/web/src/lib/types/api.ts +++ b/web/src/lib/types/api.ts @@ -80,6 +80,8 @@ export type CobaltLocalProcessingResponse = { copy: boolean, format: string, bitrate: string, + cover?: boolean, + cropCover?: boolean, }, isHLS?: boolean, From 655e7a53a2440f78d5e4e338de722c9003cf5c98 Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 26 Jun 2025 17:39:05 +0600 Subject: [PATCH 081/138] docs/api: add info about cover & cropCover --- docs/api.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/api.md b/docs/api.md index dfeea7b8..6be29bc6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -144,11 +144,13 @@ all keys in this table are optional. | `date` | `string` | release date or creation date | #### audio object -| key | type | value | -|:----------|:----------|:-------------------------------------------| -| `copy` | `boolean` | defines whether audio codec data is copied | -| `format` | `string` | output audio format | -| `bitrate` | `string` | preferred bitrate of audio format | +| key | type | value | +|:------------|:----------|:-----------------------------------------------------------| +| `copy` | `boolean` | defines whether audio codec data is copied | +| `format` | `string` | output audio format | +| `bitrate` | `string` | preferred bitrate of audio format | +| `cover` | `boolean` | whether tunnels include a cover art file (optional) | +| `cropCover` | `boolean` | whether cover art should be cropped to a square (optional) | ### picker response | key | type | value | From 84aa80e2d3a7fade398d9b11fc53d83d262107e9 Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 26 Jun 2025 17:45:01 +0600 Subject: [PATCH 082/138] api/match-action: don't add cover if metadata is disabled --- api/src/processing/match-action.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index 555ecc33..ba340dc9 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -34,8 +34,8 @@ export default function({ requestIP, originalRequest: r.originalRequest, subtitles: r.subtitles, - cover: r.cover, - cropCover: r.cropCover, + cover: !disableMetadata ? r.cover : false, + cropCover: !disableMetadata ? r.cropCover : false, }, params = {}; From bfb23c86f9da4a62afcdc19c47d3d1579182241b Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 26 Jun 2025 18:09:04 +0600 Subject: [PATCH 083/138] web/queue: add cover only to mp3 files --- web/src/lib/task-manager/queue.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/task-manager/queue.ts b/web/src/lib/task-manager/queue.ts index 73ced4d4..8244da64 100644 --- a/web/src/lib/task-manager/queue.ts +++ b/web/src/lib/task-manager/queue.ts @@ -84,7 +84,7 @@ const makeAudioArgs = (info: CobaltLocalProcessingResponse) => { const ffargs = []; - if (info.audio.cover) { + if (info.audio.cover && info.audio.format === "mp3") { ffargs.push( "-map", "0", "-map", "1", From 81a0d5e154427b030b56ef4e64f8723fe26f6e16 Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 26 Jun 2025 18:11:02 +0600 Subject: [PATCH 084/138] web/queue: scale cropped covers to 720x720 instead of 800x800 because usually thumbnails that need to be cropped are 1280x720 --- web/src/lib/task-manager/queue.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/task-manager/queue.ts b/web/src/lib/task-manager/queue.ts index 8244da64..2d439333 100644 --- a/web/src/lib/task-manager/queue.ts +++ b/web/src/lib/task-manager/queue.ts @@ -90,7 +90,7 @@ const makeAudioArgs = (info: CobaltLocalProcessingResponse) => { "-map", "1", ...(info.audio.cropCover ? [ "-c:v", "mjpeg", - "-vf", "scale=-1:800,crop=800:800", + "-vf", "scale=-1:720,crop=720:720", ] : [ "-c:v", "copy", ]), From d69100c68d038639c5cf79fd0e6466faa87ea0a8 Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 26 Jun 2025 21:32:31 +0600 Subject: [PATCH 085/138] api/tiktok: validate that redirected link is still tiktok --- api/src/processing/services/tiktok.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/src/processing/services/tiktok.js b/api/src/processing/services/tiktok.js index 4524596d..3f5ea1fc 100644 --- a/api/src/processing/services/tiktok.js +++ b/api/src/processing/services/tiktok.js @@ -24,8 +24,10 @@ export default async function(obj) { if (html.startsWith(' Date: Thu, 26 Jun 2025 22:20:09 +0600 Subject: [PATCH 086/138] api/api-keys: add allowedServices to limit or extend access it's useful for limiting access to services per key, or for overriding default list of enabled services with "all" --- api/src/core/api.js | 5 ++++- api/src/core/env.js | 2 ++ api/src/processing/url.js | 4 ++-- api/src/security/api-keys.js | 38 ++++++++++++++++++++++++++++++++++-- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/api/src/core/api.js b/api/src/core/api.js index c8ff6fbf..0487ad7a 100644 --- a/api/src/core/api.js +++ b/api/src/core/api.js @@ -245,7 +245,10 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { return fail(res, "error.api.invalid_body"); } - const parsed = extract(normalizedRequest.url); + const parsed = extract( + normalizedRequest.url, + APIKeys.getAllowedServices(req.rateLimitKey), + ); if (!parsed) { return fail(res, "error.api.link.invalid"); diff --git a/api/src/core/env.js b/api/src/core/env.js index a6623ca0..f7600f65 100644 --- a/api/src/core/env.js +++ b/api/src/core/env.js @@ -11,6 +11,7 @@ const forceLocalProcessingOptions = ["never", "session", "always"]; const youtubeHlsOptions = ["never", "key", "always"]; export const loadEnvs = (env = process.env) => { + const allServices = new Set(Object.keys(services)); const disabledServices = env.DISABLED_SERVICES?.split(',') || []; const enabledServices = new Set(Object.keys(services).filter(e => { if (!disabledServices.includes(e)) { @@ -64,6 +65,7 @@ export const loadEnvs = (env = process.env) => { instanceCount: (env.API_INSTANCE_COUNT && parseInt(env.API_INSTANCE_COUNT)) || 1, keyReloadInterval: 900, + allServices, enabledServices, customInnertubeClient: env.CUSTOM_INNERTUBE_CLIENT, diff --git a/api/src/processing/url.js b/api/src/processing/url.js index 5bb690e6..dbbda1cd 100644 --- a/api/src/processing/url.js +++ b/api/src/processing/url.js @@ -199,7 +199,7 @@ export function normalizeURL(url) { ); } -export function extract(url) { +export function extract(url, enabledServices = env.enabledServices) { if (!(url instanceof URL)) { url = new URL(url); } @@ -210,7 +210,7 @@ export function extract(url) { return { error: "link.invalid" }; } - if (!env.enabledServices.has(host)) { + if (!enabledServices.has(host)) { // show a different message when youtube is disabled on official instances // as it only happens when shit hits the fan if (new URL(env.apiURL).hostname.endsWith(".imput.net") && host === "youtube") { diff --git a/api/src/security/api-keys.js b/api/src/security/api-keys.js index 37ec66fb..c0ef7770 100644 --- a/api/src/security/api-keys.js +++ b/api/src/security/api-keys.js @@ -15,7 +15,7 @@ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12 let keys = {}, reader = null; -const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit']); +const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit', 'allowedServices']); /* Expected format pseudotype: ** type KeyFileContents = Record< @@ -24,7 +24,8 @@ const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit']); ** name?: string, ** limit?: number | "unlimited", ** ips?: CIDRString[], -** userAgents?: string[] +** userAgents?: string[], +** allowedServices?: "all" | string[], ** } ** >; */ @@ -77,6 +78,19 @@ const validateKeys = (input) => { throw "`userAgents` in details contains an invalid user agent: " + invalid_ua; } } + + if (details.allowedServices) { + const isArray = Array.isArray(details.allowedServices); + + if (isArray) { + const invalid_services = details.allowedServices.find(service => !env.allServices.has(service)); + if (invalid_services) { + throw "`allowedServices` in details contains an invalid service"; + } + } else if (details.allowedServices !== "all") { + throw "details object contains value for `allowedServices` which is not an array or `all`"; + } + } }); } @@ -112,6 +126,14 @@ const formatKeys = (keyData) => { if (data.userAgents) { formatted[key].userAgents = data.userAgents.map(generateWildcardRegex); } + + if (data.allowedServices) { + if (Array.isArray(data.allowedServices)) { + formatted[key].allowedServices = new Set(data.allowedServices); + } else { + formatted[key].allowedServices = data.allowedServices; + } + } } return formatted; @@ -230,3 +252,15 @@ export const setup = (url) => { }); } } + +export const getAllowedServices = (key) => { + if (typeof key !== "string") return; + + const allowedServices = keys[key.toLowerCase()]?.allowedServices; + if (!allowedServices) return; + + if (allowedServices === "all") { + return env.allServices; + } + return allowedServices; +} From dce9eb30c1d2a2618ea456338224ac1857738873 Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 26 Jun 2025 22:23:25 +0600 Subject: [PATCH 087/138] docs/protect-an-instance: add info about allowedServices in api keys --- docs/protect-an-instance.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/protect-an-instance.md b/docs/protect-an-instance.md index 30584102..0271375e 100644 --- a/docs/protect-an-instance.md +++ b/docs/protect-an-instance.md @@ -159,7 +159,8 @@ type KeyFileContents = Record< name?: string, limit?: number | "unlimited", ips?: (CIDRString | IPString)[], - userAgents?: string[] + userAgents?: string[], + allowedServices?: "all" | string[], } >; ``` @@ -179,6 +180,11 @@ where *`UUIDv4String`* is a stringified version of a UUIDv4 identifier. - when specified, requests with a `user-agent` that does not appear in this array will be rejected. - when omitted, any user agent can be specified to make requests with that API key. +- **`allowedServices`** is an array of allowed services or `"all"`. + - when `"all"` is specified, the key will be able to access all supported services, even if they're globally disabled via `DISABLED_SERVICES`. + - when an array of services is specified, the key will be able to access only the services included in the array. + - when omitted, the key will use the global list of supported services. + - if both `ips` and `userAgents` are set, the tokens will be limited by both parameters. - if cobalt detects any problem with your key file, it will be ignored and a warning will be printed to the console. From 8feaf5c63683d5b7ceee173122ef7bb6dc0df91c Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 26 Jun 2025 22:32:39 +0600 Subject: [PATCH 088/138] api/api-keys: replace .find() with .some() in allowedServices & also a little refactor --- api/src/security/api-keys.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/src/security/api-keys.js b/api/src/security/api-keys.js index c0ef7770..f2e9e6dc 100644 --- a/api/src/security/api-keys.js +++ b/api/src/security/api-keys.js @@ -80,10 +80,10 @@ const validateKeys = (input) => { } if (details.allowedServices) { - const isArray = Array.isArray(details.allowedServices); - - if (isArray) { - const invalid_services = details.allowedServices.find(service => !env.allServices.has(service)); + if (Array.isArray(details.allowedServices)) { + const invalid_services = details.allowedServices.some( + service => !env.allServices.has(service) + ); if (invalid_services) { throw "`allowedServices` in details contains an invalid service"; } From 900c6f27caba65dd642718b3409e5da007d864be Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 27 Jun 2025 21:47:21 +0600 Subject: [PATCH 089/138] api/tests/vimeo: allow mature video tests to fail --- api/src/util/tests/vimeo.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/src/util/tests/vimeo.json b/api/src/util/tests/vimeo.json index 6c44a47d..e28af888 100644 --- a/api/src/util/tests/vimeo.json +++ b/api/src/util/tests/vimeo.json @@ -47,6 +47,7 @@ "name": "private video", "url": "https://vimeo.com/903115595/f14d06da38", "params": {}, + "canFail": true, "expected": { "code": 200, "status": "redirect" @@ -56,6 +57,7 @@ "name": "mature video", "url": "https://vimeo.com/973212054", "params": {}, + "canFail": true, "expected": { "code": 200, "status": "redirect" From 51c5d055ecb2d58ee9afcea9936084a538c24d1c Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 28 Jun 2025 14:45:04 +0600 Subject: [PATCH 090/138] api/service-patterns/tiktok: allow longer shortLink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tiktok is a/b testing a new shortLink format that's ±19 characters long but behaves the same way as old format --- api/src/processing/service-patterns.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/processing/service-patterns.js b/api/src/processing/service-patterns.js index 989cfa63..fd6daef9 100644 --- a/api/src/processing/service-patterns.js +++ b/api/src/processing/service-patterns.js @@ -43,7 +43,7 @@ export const testers = { pattern.id?.length <= 6, "tiktok": pattern => - pattern.postId?.length <= 21 || pattern.shortLink?.length <= 13, + pattern.postId?.length <= 21 || pattern.shortLink?.length <= 21, "tumblr": pattern => pattern.id?.length < 21 From fffb31dbf035a43b10aab0c4cfd7a932456fce98 Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 28 Jun 2025 14:46:03 +0600 Subject: [PATCH 091/138] web/i18n/error/api: fix a typo in fetch.short_link --- web/i18n/en/error/api.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/i18n/en/error/api.json b/web/i18n/en/error/api.json index 93158b61..9e922745 100644 --- a/web/i18n/en/error/api.json +++ b/web/i18n/en/error/api.json @@ -34,7 +34,7 @@ "fetch.critical.core": "one of the core modules returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!", "fetch.empty": "couldn't find any media that i could download for you. are you sure you pasted the right link?", "fetch.rate": "the processing instance got rate limited by {{ service }}. try again in a few seconds!", - "fetch.short_link": "couldn't get info from the short link. are you sure it works? if it does and you still get this error, please report the issue!", + "fetch.short_link": "couldn't get info from the short link. are you sure it works? if it does and you still get this error, please report this issue!", "content.too_long": "media you requested is too long. the duration limit on this instance is {{ limit }} minutes. try something shorter instead!", From 9d818300f4dc846e2a40987bab667b13b63692c1 Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 28 Jun 2025 16:19:41 +0600 Subject: [PATCH 092/138] api/twitter: add subtitle extraction closes #1219 --- api/src/processing/match.js | 3 +- api/src/processing/services/twitter.js | 55 ++++++++++++++++++++++---- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/api/src/processing/match.js b/api/src/processing/match.js index 1e493be6..f963512b 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -83,7 +83,8 @@ export default async function({ host, patternMatch, params, isSession, isApiKey index: patternMatch.index - 1, toGif: !!params.convertGif, alwaysProxy: params.alwaysProxy, - dispatcher + dispatcher, + subtitleLang }); break; diff --git a/api/src/processing/services/twitter.js b/api/src/processing/services/twitter.js index faf2406b..8bbe0eeb 100644 --- a/api/src/processing/services/twitter.js +++ b/api/src/processing/services/twitter.js @@ -1,3 +1,4 @@ +import HLS from "hls-parser"; import { genericUserAgent } from "../../config.js"; import { createStream } from "../../stream/manage.js"; import { getCookie, updateCookie } from "../cookie/manager.js"; @@ -192,7 +193,7 @@ const testResponse = (result) => { return true; } -export default async function({ id, index, toGif, dispatcher, alwaysProxy }) { +export default async function({ id, index, toGif, dispatcher, alwaysProxy, subtitleLang }) { const cookie = await getCookie('twitter'); let syndication = false; @@ -252,6 +253,30 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) { url, filename, }); + const extractSubtitles = async (hlsUrl) => { + const mainHls = await fetch(hlsUrl).then(r => r.text()).catch(() => {}); + if (!mainHls) return; + + const subtitle = HLS.parse(mainHls)?.variants[0]?.subtitles?.find( + s => s.language.startsWith(subtitleLang) + ); + if (!subtitle) return; + + const subtitleUrl = new URL(subtitle.uri, hlsUrl).toString(); + const subtitleHls = await fetch(subtitleUrl).then(r => r.text()); + if (!subtitleHls) return; + + const finalSubtitlePath = HLS.parse(subtitleHls)?.segments?.[0].uri; + if (!finalSubtitlePath) return; + + const finalSubtitleUrl = new URL(finalSubtitlePath, hlsUrl).toString(); + + return { + url: finalSubtitleUrl, + language: subtitle.language, + }; + } + switch (media?.length) { case undefined: case 0: @@ -259,21 +284,37 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) { error: "fetch.empty" } case 1: - if (media[0].type === "photo") { + const mediaItem = media[0]; + if (mediaItem.type === "photo") { return { type: "proxy", isPhoto: true, - filename: `twitter_${id}.${getFileExt(media[0].media_url_https)}`, - urls: `${media[0].media_url_https}?name=4096x4096` + filename: `twitter_${id}.${getFileExt(mediaItem.media_url_https)}`, + urls: `${mediaItem.media_url_https}?name=4096x4096` + } + } + + let subtitles; + let fileMetadata; + if (mediaItem.type === "video" && subtitleLang) { + const hlsVariant = mediaItem.video_info?.variants?.find( + v => v.content_type === "application/x-mpegURL" + ); + if (hlsVariant) { + const { url, language } = await extractSubtitles(hlsVariant.url) || {}; + subtitles = url; + if (language) fileMetadata = { sublanguage: language }; } } return { - type: needsFixing(media[0]) ? "remux" : "proxy", - urls: bestQuality(media[0].video_info.variants), + type: subtitles || needsFixing(mediaItem) ? "remux" : "proxy", + urls: bestQuality(mediaItem.video_info.variants), filename: `twitter_${id}.mp4`, audioFilename: `twitter_${id}_audio`, - isGif: media[0].type === "animated_gif" + isGif: mediaItem.type === "animated_gif", + subtitles, + fileMetadata, } default: const proxyThumb = (url, i) => From 7298082bd5c783509d1ba83fd4b82083a4961312 Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 28 Jun 2025 16:31:39 +0600 Subject: [PATCH 093/138] api: refactor two static arrays to set --- api/src/processing/match-action.js | 4 ++-- api/src/stream/internal.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index ba340dc9..555705e8 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -6,7 +6,7 @@ import { createStream } from "../stream/manage.js"; import { splitFilenameExtension } from "../misc/utils.js"; import { convertLanguageCode } from "../misc/language-codes.js"; -const extraProcessingTypes = ["merge", "remux", "mute", "audio", "gif"]; +const extraProcessingTypes = new Set(["merge", "remux", "mute", "audio", "gif"]); export default function({ r, @@ -254,7 +254,7 @@ export default function({ // (very painful) if (!params.isHLS && responseType !== "picker") { const isPreferredWithExtra = - localProcessing === "preferred" && extraProcessingTypes.includes(params.type); + localProcessing === "preferred" && extraProcessingTypes.has(params.type); if (localProcessing === "forced" || isPreferredWithExtra) { responseType = "local-processing"; diff --git a/api/src/stream/internal.js b/api/src/stream/internal.js index b261fa10..aa8333ae 100644 --- a/api/src/stream/internal.js +++ b/api/src/stream/internal.js @@ -6,7 +6,7 @@ import { handleHlsPlaylist, isHlsResponse, probeInternalHLSTunnel } from "./inte const CHUNK_SIZE = BigInt(8e6); // 8 MB const min = (a, b) => a < b ? a : b; -const serviceNeedsChunks = ["youtube", "vk"]; +const serviceNeedsChunks = new Set(["youtube", "vk"]); async function* readChunks(streamInfo, size) { let read = 0n, chunksSinceTransplant = 0; @@ -148,7 +148,7 @@ export function internalStream(streamInfo, res) { streamInfo.headers.delete('icy-metadata'); } - if (serviceNeedsChunks.includes(streamInfo.service) && !streamInfo.isHLS) { + if (serviceNeedsChunks.has(streamInfo.service) && !streamInfo.isHLS) { return handleChunkedStream(streamInfo, res); } From c16444126e1f392bfe302cc6e56c523d55153cb4 Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 28 Jun 2025 16:37:54 +0600 Subject: [PATCH 094/138] api/env: backwards compatibility with SESSION_RATELIMIT --- api/src/core/env.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/src/core/env.js b/api/src/core/env.js index f7600f65..f26b3986 100644 --- a/api/src/core/env.js +++ b/api/src/core/env.js @@ -39,7 +39,12 @@ export const loadEnvs = (env = process.env) => { tunnelRateLimitMax: (env.TUNNEL_RATELIMIT_MAX && parseInt(env.TUNNEL_RATELIMIT_MAX)) || 40, sessionRateLimitWindow: (env.SESSION_RATELIMIT_WINDOW && parseInt(env.SESSION_RATELIMIT_WINDOW)) || 60, - sessionRateLimit: (env.SESSION_RATELIMIT_MAX && parseInt(env.SESSION_RATELIMIT_MAX)) || 10, + sessionRateLimit: + // backwards compatibility with SESSION_RATELIMIT + // till next major due to an error in docs + (env.SESSION_RATELIMIT_MAX && parseInt(env.SESSION_RATELIMIT_MAX)) + || (env.SESSION_RATELIMIT && parseInt(env.SESSION_RATELIMIT)) + || 10, durationLimit: (env.DURATION_LIMIT && parseInt(env.DURATION_LIMIT)) || 10800, streamLifespan: (env.TUNNEL_LIFESPAN && parseInt(env.TUNNEL_LIFESPAN)) || 90, From 3d2473d8ef4f3f4bd2b77713be13dcba0c0133a9 Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 28 Jun 2025 16:44:28 +0600 Subject: [PATCH 095/138] web/audio-sub-language: refactor to avoid code duplication --- web/src/lib/settings/audio-sub-language.ts | 27 +++++++++++----------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/web/src/lib/settings/audio-sub-language.ts b/web/src/lib/settings/audio-sub-language.ts index 3e9d23e4..61653844 100644 --- a/web/src/lib/settings/audio-sub-language.ts +++ b/web/src/lib/settings/audio-sub-language.ts @@ -44,11 +44,11 @@ const namedLanguages = ( name = get(t)("settings.subtitles.none"); break; default: { - let intlName = "unknown"; + let intlName; try { - intlName = new Intl.DisplayNames([lang], { type: 'language' }).of(lang) || "unknown"; + intlName = new Intl.DisplayNames([lang], { type: 'language' }).of(lang); } catch { /* */ }; - name = `${intlName} (${lang})`; + name = `${intlName || "unknown"} (${lang})`; break; } } @@ -69,16 +69,15 @@ export const namedSubtitleLanguages = () => { } export const getBrowserLanguage = (): YoutubeDubLang => { - if (typeof navigator === 'undefined') - return "original"; - - const browserLanguage = navigator.language as YoutubeDubLang; - if (youtubeDubLanguages.includes(browserLanguage)) - return browserLanguage; - - const shortened = browserLanguage.split('-')[0] as YoutubeDubLang; - if (youtubeDubLanguages.includes(shortened)) - return shortened; - + if (typeof navigator !== 'undefined') { + const browserLanguage = navigator.language as YoutubeDubLang; + if (youtubeDubLanguages.includes(browserLanguage)) { + return browserLanguage; + } + const shortened = browserLanguage.split('-')[0] as YoutubeDubLang; + if (youtubeDubLanguages.includes(shortened)) { + return shortened; + } + } return "original"; } From bc8c16f4691f321110d8a9d48dc7017b1de6df08 Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 28 Jun 2025 16:59:00 +0600 Subject: [PATCH 096/138] web/env: accept 1 as bool value --- web/src/lib/env.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/lib/env.ts b/web/src/lib/env.ts index 960d7ddb..5821699c 100644 --- a/web/src/lib/env.ts +++ b/web/src/lib/env.ts @@ -10,7 +10,8 @@ const getEnv = (_key: string) => { } const getEnvBool = (key: string) => { - return getEnv(key) === "true"; + const value = getEnv(key); + return value && ['1', 'true'].includes(value.toLowerCase()); } const variables = { From d70180b23c090eaeb0d6240cdfc31ccf203bd986 Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 28 Jun 2025 17:05:18 +0600 Subject: [PATCH 097/138] api/core: merge isApiKey and isSession into authType cuz they can't be true at the same time --- api/src/core/api.js | 7 +++---- api/src/processing/match.js | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/api/src/core/api.js b/api/src/core/api.js index 0487ad7a..248f9357 100644 --- a/api/src/core/api.js +++ b/api/src/core/api.js @@ -156,7 +156,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { return fail(res, `error.api.auth.key.${error}`); } - req.isApiKey = true; + req.authType = "key"; return next(); }); @@ -185,7 +185,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { } req.rateLimitKey = hashHmac(token, 'rate'); - req.isSession = true; + req.authType = "session"; } catch { return fail(res, "error.api.generic"); } @@ -267,8 +267,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { host: parsed.host, patternMatch: parsed.patternMatch, params: normalizedRequest, - isSession: req.isSession ?? false, - isApiKey: req.isApiKey ?? false, + authType: req.authType ?? "none", }); res.status(result.status).json(result.body); diff --git a/api/src/processing/match.js b/api/src/processing/match.js index f963512b..360ed532 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -32,7 +32,7 @@ import xiaohongshu from "./services/xiaohongshu.js"; let freebind; -export default async function({ host, patternMatch, params, isSession, isApiKey }) { +export default async function({ host, patternMatch, params, authType }) { const { url } = params; assert(url instanceof URL); let dispatcher, requestIP; @@ -69,7 +69,7 @@ export default async function({ host, patternMatch, params, isSession, isApiKey let youtubeHLS = params.youtubeHLS; const hlsEnv = env.enableDeprecatedYoutubeHls; - if (hlsEnv === "never" || (hlsEnv === "key" && !isApiKey)) { + if (hlsEnv === "never" || (hlsEnv === "key" && authType !== "key")) { youtubeHLS = false; } @@ -313,7 +313,7 @@ export default async function({ host, patternMatch, params, isSession, isApiKey let localProcessing = params.localProcessing; const lpEnv = env.forceLocalProcessing; - const shouldForceLocal = lpEnv === "always" || (lpEnv === "session" && isSession); + const shouldForceLocal = lpEnv === "always" || (lpEnv === "session" && authType === "session"); const localDisabled = (!localProcessing || localProcessing === "none"); if (shouldForceLocal && localDisabled) { From 4fc2952c5481f971b5f4b06057bde3f80a25826c Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 28 Jun 2025 17:43:46 +0600 Subject: [PATCH 098/138] web/audio-sub-language: update localized values dynamically --- web/src/lib/settings/audio-sub-language.ts | 21 +++++++++++-------- web/src/lib/types/generic.ts | 3 +++ web/src/routes/settings/audio/+page.svelte | 2 +- web/src/routes/settings/metadata/+page.svelte | 2 +- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/web/src/lib/settings/audio-sub-language.ts b/web/src/lib/settings/audio-sub-language.ts index 61653844..b2cfe7bc 100644 --- a/web/src/lib/settings/audio-sub-language.ts +++ b/web/src/lib/settings/audio-sub-language.ts @@ -1,5 +1,5 @@ -import { t } from "$lib/i18n/translations"; -import { get } from "svelte/store"; +import { t as translation } from "$lib/i18n/translations"; +import type { FromReadable } from "$lib/types/generic"; const languages = [ // most popular languages are first, according to @@ -30,18 +30,21 @@ export const subtitleLanguages = ["none", ...languages] as const; export type YoutubeDubLang = typeof youtubeDubLanguages[number]; export type SubtitleLang = typeof subtitleLanguages[number]; +type TranslationFunction = FromReadable; + const namedLanguages = ( - languages: typeof youtubeDubLanguages | typeof subtitleLanguages + languages: typeof youtubeDubLanguages | typeof subtitleLanguages, + t: TranslationFunction, ) => { return languages.reduce((obj, lang) => { let name: string; switch (lang) { case "original": - name = get(t)("settings.youtube.dub.original"); + name = t("settings.youtube.dub.original"); break; case "none": - name = get(t)("settings.subtitles.none"); + name = t("settings.subtitles.none"); break; default: { let intlName; @@ -60,12 +63,12 @@ const namedLanguages = ( }, {}) as Record; } -export const namedYoutubeDubLanguages = () => { - return namedLanguages(youtubeDubLanguages); +export const namedYoutubeDubLanguages = (t: TranslationFunction) => { + return namedLanguages(youtubeDubLanguages, t); } -export const namedSubtitleLanguages = () => { - return namedLanguages(subtitleLanguages); +export const namedSubtitleLanguages = (t: TranslationFunction) => { + return namedLanguages(subtitleLanguages, t); } export const getBrowserLanguage = (): YoutubeDubLang => { diff --git a/web/src/lib/types/generic.ts b/web/src/lib/types/generic.ts index 59844106..19f78db2 100644 --- a/web/src/lib/types/generic.ts +++ b/web/src/lib/types/generic.ts @@ -1,3 +1,5 @@ +import type { Readable } from "svelte/store"; + // more readable version of recursive partial taken from stackoverflow: // https://stackoverflow.com/a/51365037 export type RecursivePartial = { @@ -10,3 +12,4 @@ export type RecursivePartial = { export type DefaultImport = () => Promise<{ default: T }>; export type Optional = T | undefined; export type Writeable = { -readonly [P in keyof T]: T[P] }; +export type FromReadable = T extends Readable ? U : never; diff --git a/web/src/routes/settings/audio/+page.svelte b/web/src/routes/settings/audio/+page.svelte index 06b513d7..f4227daa 100644 --- a/web/src/routes/settings/audio/+page.svelte +++ b/web/src/routes/settings/audio/+page.svelte @@ -11,7 +11,7 @@ import SettingsToggle from "$components/buttons/SettingsToggle.svelte"; import SettingsDropdown from "$components/settings/SettingsDropdown.svelte"; - const displayLangs = namedYoutubeDubLanguages(); + const displayLangs = namedYoutubeDubLanguages($t); diff --git a/web/src/routes/settings/metadata/+page.svelte b/web/src/routes/settings/metadata/+page.svelte index 944ec61c..dfe02eef 100644 --- a/web/src/routes/settings/metadata/+page.svelte +++ b/web/src/routes/settings/metadata/+page.svelte @@ -12,7 +12,7 @@ import FilenamePreview from "$components/settings/FilenamePreview.svelte"; import SettingsDropdown from "$components/settings/SettingsDropdown.svelte"; - const displayLangs = namedSubtitleLanguages(); + const displayLangs = namedSubtitleLanguages($t); From bd0caac5ba19e8c969c6058bd15dbfce8b197daf Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 28 Jun 2025 17:48:31 +0600 Subject: [PATCH 099/138] web/changelogs/11.0: set a fixed commit in compare, fix env name error --- web/changelogs/11.0.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/changelogs/11.0.md b/web/changelogs/11.0.md index 06d3168a..82835549 100644 --- a/web/changelogs/11.0.md +++ b/web/changelogs/11.0.md @@ -89,7 +89,7 @@ aside from local processing, we put in a ton of effort to make the cobalt web ap - many internal tunnel improvements. - the api now returns a `429` http status code when rate limits are hit. - the `allowH265` (formerly `tiktokH265`) and `convertGif` (formerly `twitterGif`) api parameters have been renamed for clarity as they apply (or will apply) to more services. -- added a bunch of new [api instance environment variables](https://github.com/imputnet/cobalt/blob/main/docs/api-env-variables.md): `FORCE_LOCAL_PROCESSING`, `API_ENV_FILE`, `SESSION_RATELIMIT_WINDOW`, `SESSION_RATELIMIT`, `TUNNEL_RATELIMIT_WINDOW`, `TUNNEL_RATELIMIT`, `CUSTOM_INNERTUBE_CLIENT`, `YOUTUBE_SESSION_SERVER`, `YOUTUBE_SESSION_INNERTUBE_CLIENT`, `YOUTUBE_ALLOW_BETTER_AUDIO`. +- added a bunch of new [api instance environment variables](https://github.com/imputnet/cobalt/blob/main/docs/api-env-variables.md): `FORCE_LOCAL_PROCESSING`, `API_ENV_FILE`, `SESSION_RATELIMIT_WINDOW`, `SESSION_RATELIMIT_MAX`, `TUNNEL_RATELIMIT_WINDOW`, `TUNNEL_RATELIMIT_MAX`, `CUSTOM_INNERTUBE_CLIENT`, `YOUTUBE_SESSION_SERVER`, `YOUTUBE_SESSION_INNERTUBE_CLIENT`, `YOUTUBE_ALLOW_BETTER_AUDIO`. ## youtube improvements - added a new option in [audio settings](/settings/audio#youtube-better-audio) to prefer better audio quality from youtube when available. @@ -142,7 +142,7 @@ aside from local processing, we put in a ton of effort to make the cobalt web ap - removed unused packages & updated many dependencies. ## all changes are on github -like always, you can check [all commits since the 10.5 release on github](https://github.com/imputnet/cobalt/compare/41430ff...main) for even more details, if you're curious. +like always, you can check [all commits since the 10.5 release on github](https://github.com/imputnet/cobalt/compare/41430ff...a52dde7) for even more details, if you're curious. this update was made with a lot of love and care, so we hope you enjoy it as much as we enjoyed making it. From a751f81ea340f3e20d539366c209fbb91d045bff Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 28 Jun 2025 19:06:21 +0600 Subject: [PATCH 100/138] version-info: return git branch info correctly in cf workers --- packages/version-info/index.js | 4 ++++ packages/version-info/package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/version-info/index.js b/packages/version-info/index.js index 3ac1bd3e..a1f74968 100644 --- a/packages/version-info/index.js +++ b/packages/version-info/index.js @@ -39,6 +39,10 @@ export const getBranch = async () => { return process.env.CF_PAGES_BRANCH; } + if (process.env.WORKERS_CI_BRANCH) { + return process.env.WORKERS_CI_BRANCH; + } + return (await readGit('.git/HEAD')) ?.replace(/^ref: refs\/heads\//, '') ?.trim(); diff --git a/packages/version-info/package.json b/packages/version-info/package.json index e82a6201..018b6a2e 100644 --- a/packages/version-info/package.json +++ b/packages/version-info/package.json @@ -1,6 +1,6 @@ { "name": "@imput/version-info", - "version": "1.0.0", + "version": "1.0.1", "description": "helper package for cobalt that provides commit info & version from package file.", "main": "index.js", "types": "index.d.ts", From 8da71e413e7d2f647c9ac78912171080ce99bc20 Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 28 Jun 2025 20:47:08 +0600 Subject: [PATCH 101/138] api/package: bump version to 11.2 --- api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/package.json b/api/package.json index fed69e73..1611247d 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "@imput/cobalt-api", "description": "save what you love", - "version": "11.1", + "version": "11.2", "author": "imput", "exports": "./src/cobalt.js", "type": "module", From a60e94d6288f5a21fa8420348c0bad97594b760a Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 28 Jun 2025 20:47:14 +0600 Subject: [PATCH 102/138] web/package: bump version to 11.2 --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index e7dbb3e9..10eea0de 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@imput/cobalt-web", - "version": "11.0.2", + "version": "11.2", "type": "module", "private": true, "scripts": { From 214af73a1e62df7ea15ac8cb99a4f97eac2838bf Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 28 Jun 2025 21:50:43 +0600 Subject: [PATCH 103/138] docs/api: add subtitleLang, sublanguage, and update localProcessing --- docs/api.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/api.md b/docs/api.md index 6be29bc6..a6ea4dc6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -68,17 +68,18 @@ you can read [the api schema](/api/src/processing/schema.js) directly from code all keys except for `url` are optional. value options are separated by `/`. #### general -| key | type | description/value | default | -|:-----------------------|:----------|:----------------------------------------------------------------|:-----------| -| `url` | `string` | source URL | *required* | -| `audioBitrate` | `string` | `320 / 256 / 128 / 96 / 64 / 8` (kbps) | `128` | -| `audioFormat` | `string` | `best / mp3 / ogg / wav / opus` | `mp3` | -| `downloadMode` | `string` | `auto / audio / mute` | `auto` | -| `filenameStyle` | `string` | `classic / pretty / basic / nerdy` | `basic` | -| `videoQuality` | `string` | `max / 4320 / 2160 / 1440 / 1080 / 720 / 480 / 360 / 240 / 144` | `1080` | -| `disableMetadata` | `boolean` | title, artist, and other info will not be added to the file | `false` | -| `alwaysProxy` | `boolean` | always tunnel all files, even when not necessary | `false` | -| `localProcessing` | `boolean` | remux/transcode files locally instead of the server | `false` | +| key | type | description/value | default | +|:------------------|:----------|:----------------------------------------------------------------|:-----------| +| `url` | `string` | source URL | *required* | +| `audioBitrate` | `string` | `320 / 256 / 128 / 96 / 64 / 8` (kbps) | `128` | +| `audioFormat` | `string` | `best / mp3 / ogg / wav / opus` | `mp3` | +| `downloadMode` | `string` | `auto / audio / mute` | `auto` | +| `filenameStyle` | `string` | `classic / pretty / basic / nerdy` | `basic` | +| `videoQuality` | `string` | `max / 4320 / 2160 / 1440 / 1080 / 720 / 480 / 360 / 240 / 144` | `1080` | +| `disableMetadata` | `boolean` | title, artist, and other info will not be added to the file | `false` | +| `alwaysProxy` | `boolean` | always tunnel all files, even when not necessary | `false` | +| `localProcessing` | `string` | `disabled / preferred / forced` | `disabled` | +| `subtitleLang` | `string` | any valid language code, such as: `en` or `zh-CN` | *none* | #### service-specific options | key | type | description/value | default | @@ -142,6 +143,7 @@ all keys in this table are optional. | `album_artist` | `string` | album's artist or creator name | | `track` | `string` | track number or position in album | | `date` | `string` | release date or creation date | +| `sublanguage` | `string` | subtitle language code (ISO 639-2) | #### audio object | key | type | value | From aa49892e393f0b08dee6234947a496f6c5d2dabc Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 29 Jun 2025 10:53:02 +0600 Subject: [PATCH 104/138] web: update ios safari version regex since ipados pretends to be macos, there's no "iphone os" in its user agent. this (hopefully) fixes remuxing/transcoding compatibility with old ipados versions --- web/src/lib/device.ts | 2 +- web/src/lib/libav.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/device.ts b/web/src/lib/device.ts index a9686024..a5cbc324 100644 --- a/web/src/lib/device.ts +++ b/web/src/lib/device.ts @@ -38,7 +38,7 @@ if (browser) { const iPhone = ua.includes("iphone os"); const iPad = !iPhone && ua.includes("mac os") && navigator.maxTouchPoints > 0; - const iosVersion = Number(ua.match(/iphone os (\d+)_/)?.[1]); + const iosVersion = Number(ua.match(/version\/(\d+)/)?.[1]); const modernIOS = iPhone && iosVersion >= 18; const iOS = iPhone || iPad; diff --git a/web/src/lib/libav.ts b/web/src/lib/libav.ts index 64aa59b8..598000f2 100644 --- a/web/src/lib/libav.ts +++ b/web/src/lib/libav.ts @@ -9,7 +9,7 @@ const ua = navigator.userAgent.toLowerCase(); const iPhone = ua.includes("iphone os"); const iPad = !iPhone && ua.includes("mac os") && navigator.maxTouchPoints > 0; const iOS = iPhone || iPad; -const modernIOS = iOS && Number(ua.match(/iphone os (\d+)_/)?.[1]) >= 18; +const modernIOS = iOS && Number(ua.match(/version\/(\d+)/)?.[1]) >= 18; export default class LibAVWrapper { libav: Promise | null; From d25a730768b8aedbea151e07c3fbeb965742ed48 Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 29 Jun 2025 13:41:42 +0600 Subject: [PATCH 105/138] web/device: enable local processing everywhere but android chrome --- web/src/lib/device.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/web/src/lib/device.ts b/web/src/lib/device.ts index a5cbc324..417ad945 100644 --- a/web/src/lib/device.ts +++ b/web/src/lib/device.ts @@ -83,10 +83,8 @@ if (browser) { // so they're enabled only on ios 18+ for now haptics: modernIOS, - // enable local processing by default on - // desktop, ios, and firefox on android - defaultLocalProcessing: !device.is.mobile || iOS || - (device.is.android && !device.browser.chrome), + // enable local processing by default everywhere but android chrome + defaultLocalProcessing: !(device.is.android && device.browser.chrome), }; device.userAgent = navigator.userAgent; From b2c5c42ae39bdf0772cbca235a4cb582d26721b1 Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 29 Jun 2025 13:42:01 +0600 Subject: [PATCH 106/138] web/device: add supports.multithreading --- web/src/lib/device.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/lib/device.ts b/web/src/lib/device.ts index 417ad945..ad927c28 100644 --- a/web/src/lib/device.ts +++ b/web/src/lib/device.ts @@ -28,6 +28,7 @@ const device = { directDownload: false, haptics: false, defaultLocalProcessing: false, + multithreading: false, }, userAgent: "sveltekit server", } @@ -85,6 +86,7 @@ if (browser) { // enable local processing by default everywhere but android chrome defaultLocalProcessing: !(device.is.android && device.browser.chrome), + multithreading: !iOS || (iOS && iosVersion >= 18), }; device.userAgent = navigator.userAgent; From 0ac42d5b9d0aacae3ed37ac023289edfe72b1258 Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 29 Jun 2025 13:45:39 +0600 Subject: [PATCH 107/138] web/ffmpeg: define multithreading support outside of web worker context there's no navigator.maxTouchPoints in web worker context, so previously there was no way to detect whether safari is running on ipad or not --- web/src/lib/device.ts | 2 +- web/src/lib/libav.ts | 7 ------- web/src/lib/task-manager/run-worker.ts | 2 ++ web/src/lib/task-manager/runners/ffmpeg.ts | 10 ++++++++-- web/src/lib/task-manager/workers/ffmpeg.ts | 12 +++++++++--- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/web/src/lib/device.ts b/web/src/lib/device.ts index ad927c28..2b8dc894 100644 --- a/web/src/lib/device.ts +++ b/web/src/lib/device.ts @@ -86,7 +86,7 @@ if (browser) { // enable local processing by default everywhere but android chrome defaultLocalProcessing: !(device.is.android && device.browser.chrome), - multithreading: !iOS || (iOS && iosVersion >= 18), + multithreading: !iOS || iosVersion >= 18, }; device.userAgent = navigator.userAgent; diff --git a/web/src/lib/libav.ts b/web/src/lib/libav.ts index 598000f2..6c154022 100644 --- a/web/src/lib/libav.ts +++ b/web/src/lib/libav.ts @@ -5,12 +5,6 @@ import EncodeLibAV from "@imput/libav.js-encode-cli"; import type { FfprobeData } from "fluent-ffmpeg"; import type { FFmpegProgressCallback, FFmpegProgressEvent, FFmpegProgressStatus, RenderParams } from "$lib/types/libav"; -const ua = navigator.userAgent.toLowerCase(); -const iPhone = ua.includes("iphone os"); -const iPad = !iPhone && ua.includes("mac os") && navigator.maxTouchPoints > 0; -const iOS = iPhone || iPad; -const modernIOS = iOS && Number(ua.match(/version\/(\d+)/)?.[1]) >= 18; - export default class LibAVWrapper { libav: Promise | null; concurrency: number; @@ -38,7 +32,6 @@ export default class LibAVWrapper { this.libav = constructor({ ...options, variant: undefined, - yesthreads: !iOS || modernIOS, base: '/_libav' }); } diff --git a/web/src/lib/task-manager/run-worker.ts b/web/src/lib/task-manager/run-worker.ts index 021fda0d..b9994661 100644 --- a/web/src/lib/task-manager/run-worker.ts +++ b/web/src/lib/task-manager/run-worker.ts @@ -1,4 +1,5 @@ import { get } from "svelte/store"; +import { device } from "$lib/device"; import { queue, itemError } from "$lib/state/task-manager/queue"; import { runFFmpegWorker } from "$lib/task-manager/runners/ffmpeg"; @@ -42,6 +43,7 @@ export const startWorker = async ({ worker, workerId, dependsOn, parentId, worke workerArgs.ffargs, workerArgs.output, worker, + device.supports.multithreading, /*resetStartCounter=*/true, ); } else { diff --git a/web/src/lib/task-manager/runners/ffmpeg.ts b/web/src/lib/task-manager/runners/ffmpeg.ts index 43d75cbe..0a83f3fb 100644 --- a/web/src/lib/task-manager/runners/ffmpeg.ts +++ b/web/src/lib/task-manager/runners/ffmpeg.ts @@ -16,7 +16,8 @@ export const runFFmpegWorker = async ( args: string[], output: FileInfo, variant: 'remux' | 'encode', - resetStartCounter = false + yesthreads: boolean, + resetStartCounter = false, ) => { const worker = new FFmpegWorker(); @@ -34,7 +35,11 @@ export const runFFmpegWorker = async ( startAttempts++; if (startAttempts <= 10) { killWorker(worker, unsubscribe, startCheck); - return await runFFmpegWorker(workerId, parentId, files, args, output, variant); + return await runFFmpegWorker( + workerId, parentId, + files, args, output, + variant, yesthreads + ); } else { killWorker(worker, unsubscribe, startCheck); return itemError(parentId, workerId, "queue.worker_didnt_start"); @@ -54,6 +59,7 @@ export const runFFmpegWorker = async ( files, args, output, + yesthreads, } }); diff --git a/web/src/lib/task-manager/workers/ffmpeg.ts b/web/src/lib/task-manager/workers/ffmpeg.ts index cfb4868d..7f7577f2 100644 --- a/web/src/lib/task-manager/workers/ffmpeg.ts +++ b/web/src/lib/task-manager/workers/ffmpeg.ts @@ -1,7 +1,13 @@ import LibAVWrapper from "$lib/libav"; import type { FileInfo } from "$lib/types/libav"; -const ffmpeg = async (variant: string, files: File[], args: string[], output: FileInfo) => { +const ffmpeg = async ( + variant: string, + files: File[], + args: string[], + output: FileInfo, + yesthreads: boolean = false, +) => { if (!(files && output && args)) { self.postMessage({ cobaltFFmpegWorker: { @@ -25,7 +31,7 @@ const ffmpeg = async (variant: string, files: File[], args: string[], output: Fi }) }); - ff.init({ variant }); + ff.init({ variant, yesthreads }); const error = (code: string) => { self.postMessage({ @@ -122,6 +128,6 @@ const ffmpeg = async (variant: string, files: File[], args: string[], output: Fi self.onmessage = async (event: MessageEvent) => { const ed = event.data.cobaltFFmpegWorker; if (ed?.variant && ed?.files && ed?.args && ed?.output) { - await ffmpeg(ed.variant, ed.files, ed.args, ed.output); + await ffmpeg(ed.variant, ed.files, ed.args, ed.output, ed.yesthreads); } } From 7aa128d9ccd07775ae4869b1245814b0705c9312 Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 29 Jun 2025 18:11:09 +0600 Subject: [PATCH 108/138] web/package: bump version to 11.2.1 --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index 10eea0de..10d05a2d 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@imput/cobalt-web", - "version": "11.2", + "version": "11.2.1", "type": "module", "private": true, "scripts": { From 0a069c875d3e08a2b19806191b9d1247f912d8ed Mon Sep 17 00:00:00 2001 From: wukko Date: Mon, 30 Jun 2025 18:42:23 +0600 Subject: [PATCH 109/138] docs/api: specify that ISO 639-1 language code is expected --- docs/api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index a6ea4dc6..47fc7115 100644 --- a/docs/api.md +++ b/docs/api.md @@ -79,14 +79,14 @@ all keys except for `url` are optional. value options are separated by `/`. | `disableMetadata` | `boolean` | title, artist, and other info will not be added to the file | `false` | | `alwaysProxy` | `boolean` | always tunnel all files, even when not necessary | `false` | | `localProcessing` | `string` | `disabled / preferred / forced` | `disabled` | -| `subtitleLang` | `string` | any valid language code, such as: `en` or `zh-CN` | *none* | +| `subtitleLang` | `string` | any valid ISO 639-1 language code | *none* | #### service-specific options | key | type | description/value | default | |:------------------------|:----------|:--------------------------------------------------|:--------| | `youtubeVideoCodec` | `string` | `h264 / av1 / vp9` | `h264` | | `youtubeVideoContainer` | `string` | `auto / mp4 / webm / mkv` | `auto` | -| `youtubeDubLang` | `string` | any valid language code, such as: `en` or `zh-CN` | *none* | +| `youtubeDubLang` | `string` | any valid ISO 639-1 language code | *none* | | `convertGif` | `boolean` | convert twitter gifs to the actual GIF format | `true` | | `allowH265` | `boolean` | allow H265/HEVC videos from tiktok/xiaohongshu | `false` | | `tiktokFullAudio` | `boolean` | download the original sound used in a video | `false` | From 33f2c4e1747da9e66e619104b9a9029e4e06e794 Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 1 Jul 2025 00:03:11 +0600 Subject: [PATCH 110/138] web/changelogs: add 11.2 changelog --- web/changelogs/11.2.md | 89 ++++++++++++++++++ web/static/update-banners/meowth_sunrise.webp | Bin 0 -> 93372 bytes 2 files changed, 89 insertions(+) create mode 100644 web/changelogs/11.2.md create mode 100644 web/static/update-banners/meowth_sunrise.webp diff --git a/web/changelogs/11.2.md b/web/changelogs/11.2.md new file mode 100644 index 00000000..34629289 --- /dev/null +++ b/web/changelogs/11.2.md @@ -0,0 +1,89 @@ +--- +title: "local processing for everyone, subtitles, audio covers, and more" +date: "30 June, 2025" +banner: + file: "meowth_sunrise.webp" + alt: "meowth plush in a forest looking at the rising sun between the trees." +--- + +it's summertime! even though it's been rainy for us lately, the sun is right on the horizon, just like this cobalt update. we improved local processing, added long-awaited features, and improved a ton of other stuff. **downloading from youtube is back, btw**. + +here's what's new since 11.0: + +## on-device media processing +local processing is now enabled for everyone by default! it allows for faster downloading, file consistency, and best media compatibility. in this update, we optimized it to work on older browsers, just so no one's missing out on cobalt due to having outdated software. + +thanks to local processing, we were able to add **audio covers** in this update. cobalt will automatically add covers/thumbnails from youtube or soundcloud, and it'll be cropped to a square when needed. really cool stuff, and it just works! + +please let us know if local processing doesn't work properly on your device, we'll try to improve it! + +## video subtitles +we added support for downloading videos with subtitles! in this update, we added full support for subtitles from: `youtube`, `twitter`, `tiktok`, `vimeo`, `loom`, `vk video`, and `rutube`. we'll keep adding support for more services in the future! + +to download subtitles, just pick your preferred language in [metadata settings](/settings/metadata#subtitles)! cobalt will add subtitles in this language if they're available. + +pro-tip: if you don't need audio, you can save a bit of storage by switching to the "mute" mode on the home page. you'll get a mute video with subtitles and the rest of the metadata! + +don't want metadata or subtitles? just [disable metadata](/settings/metadata#metadata) in settings, and cobalt won't add anything. + +## youtube downloading +downloading from youtube on the main instance is restored! sorry that it took a bit over a week; we were trying our best to speed it up. + +hopefully it'll last for a while, but we think downloading from youtube will get significantly more annoying/complex in next few weeks-months. **right now is the best time to download everything you've been putting off**, either with cobalt or other tools. + +we're not trying to scare you; it's our educated guess based on what youtube has been doing lately: +- roll out of SABR & related limitations for more clients. SABR is Server ABR, Google's proprietary HLS alternative, controlled by the server. +- growing potoken enforcement. +- various other experiments to restrict "unauthorized access". + +we currently have no exact plan on how to handle SABR in cobalt, but we will try to figure it out. for now, we're using youtube clients that don't have it enforced, but we have no clue for how long this will last. + +by the way, we also made it possible to [choose any preferred media container](/settings/video#youtube-container) independently from the youtube video codec. could be useful for this occasion! + +## general service improvements +- added more metadata to audio files from soundcloud. +- added support for `/groups/` vimeo links. +- added support for ``/v/:id` youtube links. +- added support for new share links from tiktok. +- added support for more vk video links. +- pinterest now returns an appropriate error when a pin is unavailable. +- AI dubs on youtube are no longer accidentally selected as default tracks. +- youtube HLS preference is now deprecated, but can be enabled on a self-hosted instance via `ENABLE_DEPRECATED_YOUTUBE_HLS` in env. + +## web app improvements +- improved compatibility of local processing & related code with older browsers. +- disabled multithreading in old mobile safari, making it possible to use local processing on iOS 15+. +- local processing workers: + - the fetch worker is now less sensitive to network-related errors and returns a descriptive error whenever necessary. + - the ffmpeg worker now returns an appropriate error when a required stream is missing. + - the generic crash error is now localized. + - added a default file icon in case cobalt can't detect the file type. +- made frontend compatible with static cloudflare workers. +- most used languages in [subtitle](/settings/metadata#subtitles) and [audio track](/settings/audio#youtube-dub) dropdowns are now on top. +- default values of subtitle/audio track dropdowns are now localized. +- translated all UI strings to russian. +- fixed overflow in the processing queue. +- updated a bunch of localization strings. +- slightly updated the update notification & fixed its location in RTL layouts. + +## processing instance improvements +- fixed HLS downloading from soundcloud that was accidentally broken in the 11.0 update. +- fixed dynamic env reloading. +- added console messages about dynamic env changes. +- `SESSION_RATELIMIT` is now `SESSION_RATELIMIT_MAX`, but the old name remains valid until the next major update. this is a result of a typo in 11.0, sorry! +- `localProcessing` is now `disabled | preferred | forced`, not a boolean. 11.2 accepts boolean values, but this will be removed in a future version. +- added `subtitleLang`, which is any valid ISO 639-1 language code. +- removed backwards compatibility with `twitterGif` and `tiktokH265`. +- updated `local-processing` response in correlation to addition of subtitles and audio covers. +- a lot of refactoring. + +for up-to-date info about instance variables, check the docs on github: +- [processing instance environment variables](https://github.com/imputnet/cobalt/blob/main/docs/api-env-variables.md). +- [api documentation](https://github.com/imputnet/cobalt/blob/main/docs/api.md). + +## all changes are on github +as usual, you can check [all commits since the 11.0 release on github](https://github.com/imputnet/cobalt/compare/a52dde7...main) for even more details and exact code changes. + +we hope that you enjoy this update and have a great rest of your day! + +\~ your friends at imput ❤️ diff --git a/web/static/update-banners/meowth_sunrise.webp b/web/static/update-banners/meowth_sunrise.webp new file mode 100644 index 0000000000000000000000000000000000000000..645781a12dd887783eeb70d38b0908b20c726d5e GIT binary patch literal 93372 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%0DyHIHyI4*9abnxLcyqF7{^ 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%0DjG8+%nF!OdLTqldB2*UmycS{TyMeZd@5QA+>hW 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 PLheZsr6L4z*^7Vx6zN_) literal 0 HcmV?d00001 From 0cb64dd3f9fd3a6ae87799ce2440649269304c9e Mon Sep 17 00:00:00 2001 From: imput project translators Date: Mon, 30 Jun 2025 13:12:12 +0000 Subject: [PATCH 111/138] web/i18n/ru: add russian translation Co-authored-by: wukko Co-authored-by: Damir Modyarov Co-authored-by: 71d1k <71d1k@users.noreply.github.com> Co-authored-by: Alexey Muravyev Co-authored-by: GreenMonster362905 Co-authored-by: Ilya Co-authored-by: Kurt Co-authored-by: Nikita <50026919+nubovik01@users.noreply.github.com> Co-authored-by: Philipp Co-authored-by: Soroka Co-authored-by: aksephi Co-authored-by: azy61b Co-authored-by: ilia-21 Co-authored-by: imput project translators Co-authored-by: jj Co-authored-by: v1s7 --- web/i18n/ru/a11y/dialog.json | 5 ++ web/i18n/ru/a11y/donate.json | 4 ++ web/i18n/ru/a11y/queue.json | 5 ++ web/i18n/ru/a11y/save.json | 11 +-- web/i18n/ru/about.json | 30 ++++++++ web/i18n/ru/about/credits.md | 94 +++++++++++++++++++++++++ web/i18n/ru/about/general.md | 79 +++++++++++++++++++++ web/i18n/ru/about/privacy.md | 129 ++++++++++++++++++++++++++++++++++ web/i18n/ru/about/terms.md | 69 ++++++++++++++++++ web/i18n/ru/button.json | 27 +++++++ web/i18n/ru/dialog.json | 16 +++++ web/i18n/ru/donate.json | 29 ++++++++ web/i18n/ru/error.json | 8 +++ web/i18n/ru/error/api.json | 51 ++++++++++++++ web/i18n/ru/error/queue.json | 19 +++++ web/i18n/ru/general.json | 3 +- web/i18n/ru/notification.json | 4 ++ web/i18n/ru/queue.json | 13 ++++ web/i18n/ru/receiver.json | 7 ++ web/i18n/ru/remux.json | 8 +++ web/i18n/ru/save.json | 12 +++- web/i18n/ru/settings.json | 129 ++++++++++++++++++++++++++++++++++ web/i18n/ru/tabs.json | 2 +- web/i18n/ru/updates.json | 4 ++ 24 files changed, 750 insertions(+), 8 deletions(-) create mode 100644 web/i18n/ru/a11y/dialog.json create mode 100644 web/i18n/ru/a11y/donate.json create mode 100644 web/i18n/ru/a11y/queue.json create mode 100644 web/i18n/ru/about.json create mode 100644 web/i18n/ru/about/credits.md create mode 100644 web/i18n/ru/about/general.md create mode 100644 web/i18n/ru/about/privacy.md create mode 100644 web/i18n/ru/about/terms.md create mode 100644 web/i18n/ru/button.json create mode 100644 web/i18n/ru/dialog.json create mode 100644 web/i18n/ru/donate.json create mode 100644 web/i18n/ru/error.json create mode 100644 web/i18n/ru/error/api.json create mode 100644 web/i18n/ru/error/queue.json create mode 100644 web/i18n/ru/notification.json create mode 100644 web/i18n/ru/queue.json create mode 100644 web/i18n/ru/receiver.json create mode 100644 web/i18n/ru/remux.json create mode 100644 web/i18n/ru/settings.json create mode 100644 web/i18n/ru/updates.json diff --git a/web/i18n/ru/a11y/dialog.json b/web/i18n/ru/a11y/dialog.json new file mode 100644 index 00000000..ea7c32ce --- /dev/null +++ b/web/i18n/ru/a11y/dialog.json @@ -0,0 +1,5 @@ +{ + "picker.item.photo": "превью фотографии", + "picker.item.video": "превью видео", + "picker.item.gif": "превью gif" +} diff --git a/web/i18n/ru/a11y/donate.json b/web/i18n/ru/a11y/donate.json new file mode 100644 index 00000000..93561b71 --- /dev/null +++ b/web/i18n/ru/a11y/donate.json @@ -0,0 +1,4 @@ +{ + "share.qr.expand": "qr-код. нажми, чтобы развернуть.", + "share.qr.collapse": "развёрнутый qr-код. нажми, чтобы свернуть." +} diff --git a/web/i18n/ru/a11y/queue.json b/web/i18n/ru/a11y/queue.json new file mode 100644 index 00000000..3921c99b --- /dev/null +++ b/web/i18n/ru/a11y/queue.json @@ -0,0 +1,5 @@ +{ + "status.completed": "очередь обработки. все задачи завершены.", + "status.ongoing": "очередь обработки. есть текущие задачи.", + "status.default": "очередь обработки" +} diff --git a/web/i18n/ru/a11y/save.json b/web/i18n/ru/a11y/save.json index d6def5e5..5cf07427 100644 --- a/web/i18n/ru/a11y/save.json +++ b/web/i18n/ru/a11y/save.json @@ -1,9 +1,12 @@ { - "link_area": "зона вставки ссылки", - "clear_input": "clear input", + "link_area": "область ввода ссылки", + "clear_input": "очистить поле ввода", "download": "скачать", "download.think": "обрабатываю ссылку...", "download.check": "проверяю загрузку...", - "download.done": "загрузка завершена!", - "download.error": "ошибка загрузки" + "download.done": "загрузка завершена", + "download.error": "ошибка загрузки", + "link_area.turnstile": "область ввода ссылки. проверяю, что ты не робот.", + "tutorial.shortcut.photos": "добавить команду \"в фото\"", + "tutorial.shortcut.files": "добавить команду \"в файлы\"" } diff --git a/web/i18n/ru/about.json b/web/i18n/ru/about.json new file mode 100644 index 00000000..87dacd5a --- /dev/null +++ b/web/i18n/ru/about.json @@ -0,0 +1,30 @@ +{ + "page.general": "что такое кобальт?", + "heading.general": "общие условия", + "heading.saving": "скачивание", + "heading.encryption": "шифрование", + "heading.abuse": "сообщение о злоупотреблении", + "heading.motivation": "мотивация", + "heading.licenses": "лицензии", + "heading.summary": "лучший способ сохранять то, что ты любишь", + "page.community": "сообщество и поддержка", + "page.privacy": "конфиденциальность", + "page.terms": "условия и этика", + "page.credits": "благодарности и лицензии", + "heading.testers": "бета-тестеры", + "heading.community": "открытое сообщество", + "heading.local": "обработка на устройстве", + "heading.plausible": "анонимная аналитика трафика", + "heading.cloudflare": "веб-приватность и безопасность", + "heading.responsibility": "ответственности пользователя", + "support.github": "смотри исходный код кобальта, вноси свой вклад или сообщай о проблемах", + "support.discord": "общайся с сообществом и разработчиками кобальта или попроси о помощи", + "support.description.issue": "если ты хочешь сообщить о баге или какой-то другой повторяющейся проблеме, то делай это на github.", + "support.description.help": "используй discord для любых других вопросов. чётко опиши проблему в #cobalt-support, иначе никто не сможет тебе помочь.", + "support.twitter": "следи за обновлениями и разработкой кобальта в своей ленте твиттера", + "support.telegram": "следи за обновлениями кобальта в телеграм-канале", + "support.description.best-effort": "вся поддержка осуществляется по мере возможности и не гарантируется, а ответ может занять какое-то время.", + "heading.privacy_efficiency": "лучшая приватность и эффективность", + "heading.partners": "партнёры", + "support.bluesky": "следи за обновлениями и разработкой кобальта в своей ленте bluesky" +} diff --git a/web/i18n/ru/about/credits.md b/web/i18n/ru/about/credits.md new file mode 100644 index 00000000..a58bd223 --- /dev/null +++ b/web/i18n/ru/about/credits.md @@ -0,0 +1,94 @@ + + +

+ + +кобальт сделан с любовью и заботой руками [imput](https://imput.net/) ❤️ + +мы маленькая команда из двух человек, но мы очень усердно работаем, чтобы делать +классный софт, который приносит пользу всем. если тебе нравится то, что мы +делаем, поддержи нас на [странице донатов](/donate)! +
+ +
+ + +огромное спасибо нашим тестерам за то, что они тестировали обновления заранее и +следили за их стабильностью. они ещё помогли нам выпустить cobalt 10! + + +все ссылки внешние и ведут на их личные сайты или соцсети. +
+ +
+ + +часть инфраструктуры кобальта предоставлена нашим давним партнёром, +[royalehosting.net]({partners.royalehosting})! +
+ +
+ + +мяубальт — это шустрый маскот кобальта, очень выразительный кот, который любит +быстрый интернет. + +весь потрясающий арт мяубальта, который ты видишь в кобальте, был сделан +[GlitchyPSI](https://glitchypsi.xyz/). он ещё и оригинальный создатель этого +персонажа. + +imput владеет юридическими правами на дизайн персонажа мяубальта, но не на +конкретные арты, которые были созданы GlitchyPSI. + +мы любим мяубальта, поэтому мы вынуждены установить пару правил, чтобы его +защитить: +- ты не можешь использовать дизайн персонажа мяубальта ни в какой форме, кроме + фанарта. +- ты не можешь использовать дизайн или арты мяубальта в коммерческих целях. +- ты не можешь использовать дизайн или арты мяубальта в своих проектах. +- ты не можешь использовать или изменять работы GlitchyPSI с мяубальтом ни в + каком виде. + +если ты нарисуешь фанарт мяубальта, не стесняйся делиться им в [нашем +дискорд-сервере](/about/community), мы с нетерпением ждём! +
+ +
+ + +код api (сервера обработки) кобальта — open source и распространяется по +лицензии [AGPL-3.0]({docs.apiLicense}). + +код фронтенда кобальта — [source first](https://sourcefirst.com/) и +распространяется по лицензии [CC-BY-NC-SA 4.0]({docs.webLicense}). + +нам пришлось сделать фронтенд source first, чтобы грифтеры не наживались на +нашем труде и не создавали вредоносные клоны для обмана людей и порче нашей +репутации. кроме коммерческого использования, у этого типа лицензии те же +принципы, что и у многих open source лицензий. + +мы используем много опенсорсных библиотек, но также создаём и распространяем +свои собственные. полный список зависимостей можно посмотреть на +[github]({contacts.github})! +
diff --git a/web/i18n/ru/about/general.md b/web/i18n/ru/about/general.md new file mode 100644 index 00000000..1657ca7b --- /dev/null +++ b/web/i18n/ru/about/general.md @@ -0,0 +1,79 @@ + + +
+ + +кобальт помогает сохранять что угодно с твоих любимых сайтов: видео, аудио, фото +или гифки. просто вставь ссылку и вперёд! + +никакой рекламы, трекеров, платных подписок и прочей ерунды. просто удобное +веб-приложение, которое работает где угодно и когда угодно. +
+ +
+ + +кобальт был создан для всеобщего блага, чтобы защитить людей от рекламы и +вредоносных программ, которые навязывают альтернативные загрузчики. мы верим, +что лучший софт — безопасный, открытый и доступный. все проекты imput следуют +этим принципам. +
+ +
+ + +все запросы к бэкенду анонимны, и вся инфа о потенциальных файловых туннелях +зашифрована. у нас строгая политика нулевых логов, мы *никогда* не храним +идентифицирующую инфу о людях и никого не отслеживаем. + +если запрос требует дополнительной обработки, например ремукса или +транскодирования, то кобальт обрабатывает медиафайлы прямо на твоём устройстве. +это обеспечивает максимальную эффективность и приватность. + +если твоё устройство не поддерживает локальную обработку, то вместо неё +используется серверная обработка в реальном времени. в этом сценарии +обработанные медиаданные передаются напрямую клиенту, никогда не сохраняясь на +диске сервера. + +ты можешь [включить принудительное туннелирование](/settings/privacy#tunnel), +чтобы ещё сильнее повысить приватность. когда оно включено, кобальт будет +туннелировать все скачиваемые файлы, а не только те, которым это необходимо. +никто не узнает, откуда и что ты скачиваешь, даже твой провайдер. всё, что они +увидят, это то, что ты используешь инстанс кобальта. +
+ +
+ + +кобальт используют бесчисленные артисты, преподаватели и прочие создатели +контента, чтобы заниматься любимым делом. мы всегда на связи с нашим сообществом +и работаем вместе, чтобы делать кобальт ещё полезнее. не стесняйся +[присоединиться к разговору](/about/community)! + +мы верим, что будущее интернета — открытое и свободное, поэтому кобальт +опубликован с [открытым исходным кодом](https://sourcefirst.com/) и его можно +легко [захостить самому]({docs.instanceHosting}). + +если твой друг хостит инстанс обработки, просто попроси у него домен и [добавь +его в настройках инстанса](/settings/instances#community). + +ты можешь посмотреть исходный код и внести свой вклад [на +github]({contacts.github}) в любое время. мы рады любым предложениям и помощи! +
diff --git a/web/i18n/ru/about/privacy.md b/web/i18n/ru/about/privacy.md new file mode 100644 index 00000000..d8522f99 --- /dev/null +++ b/web/i18n/ru/about/privacy.md @@ -0,0 +1,129 @@ + + +
+ + +политика конфиденциальности кобальта проста: мы ничего не собираем и не храним о +тебе. то, что ты делаешь, — это исключительно твоё дело, а не наше или чьё-либо +ещё. + +эти условия применяются только при использовании официального инстанса кобальта. +в других случаях, возможно, придётся обратиться к хостеру инстанса за точной +информацией. +
+ +
+ + +инструменты, которые используют обработку на устройстве, работают офлайн, +локально и никогда никуда не отправляют обработанные данные. они явно помечены +как таковые, когда это применимо. +
+ +
+ + +при использовании функции сохранения, кобальту может понадобиться проксировать +или ремуксировать/транскодировать файлы. если это так, то для этой цели +создаётся временный туннель, и минимально необходимая информация о медиа +хранится в течение 90 секунд. + +на неизменённом и официальном инстансе кобальта **все данные туннеля шифруются +ключом, к которому имеет доступ только конечный пользователь**. + +зашифрованные данные туннеля могут включать: +- название исходного сервиса. +- исходные ссылки на медиафайлы. +- необходимые внутренние аргументы для различения типов обработки. +- ключевые метаданные файла (сгенерированное имя, заголовок, автор, год + создания, данные об авторских правах). +- минимальная информация об исходном запросе, которая может быть использована + для восстановления туннеля после ошибки ссылки во время скачивания. + +эти данные безвозвратно удаляются из оперативной памяти сервера через 90 секунд. +никто не имеет доступа к кэшированным данным туннеля, даже владельцы инстансов, +если исходный код кобальта не изменён. + +медиаданные из туннелей нигде не хранятся/кэшируются. всё обрабатывается в +реальном времени, даже при ремуксинге и транскодировании. туннели кобальта +работают как анонимный прокси. + +если твоё устройство поддерживает локальную обработку, то зашифрованный туннель +содержит намного меньше информации, потому что она возвращается клиенту. + +смотри [соответствующий исходный код на +github](https://github.com/imputnet/cobalt/tree/main/api/src/stream), чтобы +узнать больше о том, как это работает. +
+ +
+ + +временно хранящиеся данные туннеля шифруются с использованием стандарта AES-256. +ключи расшифровки включены только в ссылку доступа и никогда не +логируются/кэшируются/хранятся где-либо. только конечный пользователь имеет +доступ к ссылке и ключам шифрования. ключи генерируются уникально для каждого +запрошенного туннеля. +
+ +{#if env.PLAUSIBLE_ENABLED} +
+ + +мы используем [plausible](https://plausible.io/), чтобы знать приблизительное +число активных пользователей кобальта, полностью анонимно. никакая +идентифицирующая информация о тебе или твоих запросах никогда не хранится. все +данные анонимизированы и агрегированы. мы сами хостим и управляем [инстансом +plausible](https://{env.PLAUSIBLE_HOST}/), который использует кобальт. + +plausible не использует куки и полностью соответствует GDPR, CCPA и PECR. + +если ты хочешь отказаться от анонимной аналитики, то это можно сделать в +[настройках приватности](/settings/privacy#analytics). после отказа скрипт +plausible не будет загружаться. + +[узнай больше о преданности plausible к +приватности](https://plausible.io/privacy-focused-web-analytics). +
+{/if} + +
+ + +мы используем сервисы cloudflare для: +- защиты от ddos и абьюза. +- защиты от ботов (cloudflare turnstile). +- хостинга и деплоя статического веб-приложения (cloudflare workers). + +всё это необходимо для обеспечения лучшего опыта для всех. cloudflare — наиболее +приватный и надёжный провайдер всех упомянутых решений из всех известных нам +провайдеров. + +cloudflare полностью соответствует требованиям GDPR и HIPAA. + +[узнай больше о преданности cloudflare к +приватности](https://www.cloudflare.com/trust-hub/privacy-and-data-protection/). +
diff --git a/web/i18n/ru/about/terms.md b/web/i18n/ru/about/terms.md new file mode 100644 index 00000000..edb6df38 --- /dev/null +++ b/web/i18n/ru/about/terms.md @@ -0,0 +1,69 @@ + + +
+ + +эти условия применяются только при использовании официального инстанса кобальта. +в других случаях, возможно, придётся обратиться к хостеру инстанса за точной +информацией. +
+ +
+ + +функция сохранения упрощает скачивание контента из интернета, и мы не несём +никакой ответственности за то, как будет использоваться сохранённый контент. + +серверы обработки работают как продвинутые прокси и никогда не записывают +запрошенный контент на диск. всё происходит в оперативной памяти и полностью +удаляется после завершения туннеля. у нас нет логов загрузок, и мы не можем +никого идентифицировать. + +подробнее о том, как работают туннели, можно узнать в [политике +конфиденциальности](/about/privacy). +
+ +
+ + +ты (конечный пользователь) несёшь ответственность за то, что делаешь с нашими +инструментами, как используешь и распространяешь полученный контент. пожалуйста, +уважай чужой труд и всегда указывай авторов. убедись, что ты не нарушаешь +никаких условий или лицензий. + +при использовании в образовательных целях всегда ссылайся на источники и +указывай авторов. + +добросовестное использование и указание авторства приносят пользу всем. +
+ +
+ + +у нас нет возможности автоматически выявлять злоупотребления, так как кобальт +полностью анонимен. однако, есть возможность сообщить нам о такой деятельности +по почте, и мы сделаем всё возможное, чтобы принять нужные меры вручную: +abuse[at]imput.net + +**этот адрес не предназначен для поддержки пользователей. ты не получишь ответ, +если твой запрос не связан со злоупотреблениями.** + +если у тебя возникли проблемы с работой кобальта, то ты можешь обратиться за +помощью любым удобным способом на [странице поддержки и +сообщества](/about/community). +
diff --git a/web/i18n/ru/button.json b/web/i18n/ru/button.json new file mode 100644 index 00000000..d87f75b8 --- /dev/null +++ b/web/i18n/ru/button.json @@ -0,0 +1,27 @@ +{ + "download.audio": "скачать аудио", + "import": "импортировать", + "copied": "скопировано", + "copy": "скопировать", + "share": "поделиться", + "download": "скачать", + "no": "нет", + "yes": "да", + "save": "скачать", + "continue": "продолжить", + "done": "готово", + "reset": "сбросить", + "cancel": "отменить", + "export": "экспортировать", + "gotit": "понятно", + "copy.section": "скопировать ссылку на раздел", + "clear_input": "очистить поле ввода", + "show_input": "показать ввод", + "hide_input": "скрыть ввод", + "restore_input": "восстановить ввод", + "clear": "очистить", + "remove": "убрать", + "clear_cache": "очистить кэш", + "retry": "повторить", + "delete": "удалить" +} diff --git a/web/i18n/ru/dialog.json b/web/i18n/ru/dialog.json new file mode 100644 index 00000000..8e9d538b --- /dev/null +++ b/web/i18n/ru/dialog.json @@ -0,0 +1,16 @@ +{ + "picker.title": "что сохранить?", + "saving.title": "как сохранить?", + "saving.timeout": "кобальт попытался сохранить файл автоматически, но твой браузер остановил это. выбери способ вручную.", + "reset_settings.title": "сбросить все настройки?", + "reset_settings.body": "ты точно хочешь сбросить все настройки? это действие мгновенное и необратимое.", + "picker.description.phone": "нажми на то, что хочешь скачать. картинки также можно скачать долгим нажатием.", + "picker.description.desktop": "кликни на то, что хочешь скачать. картинки также можно скачать через контекстное меню.", + "picker.description.ios": "нажми на то, что хочешь скачать через команду siri. картинки также можно скачать долгим нажатием.", + "saving.blocked": "кобальт попытался открыть файл в новой вкладке, но твой браузер заблокировал это. разреши всплывающие окна для кобальта, чтобы избежать этого в следующий раз.", + "clear_cache.title": "очистить весь кэш?", + "import.body": "импорт неизвестных или повреждённых файлов может неожиданно изменить или сломать работу кобальта. импортируй только те файлы, которые ты экспортировал сам и не изменял. если кто-то попросил тебя импортировать этот файл — не делай этого.\n\nмы не несём ответственности за любой вред, причинённый импортом неизвестных файлов настроек.", + "safety.custom_instance.body": "сторонние инстансы могут быть опасны для твоей приватности и безопасности.\n\nвредоносные инстансы могут:\n1. перенаправлять тебя с кобальта и пытаться обмануть.\n2. записывать всю информацию о твоих запросах, хранить её вечно и использовать для слежки за тобой.\n3. скачивать вредоносные файлы (например, вирусы).\n4. заставлять тебя смотреть рекламу или платить за скачивание.\n\nпосле этого момента мы не сможем тебя защитить. пожалуйста, будь осторожен с выбором инстанса и всегда доверяй своей интуиции. если что-то кажется странным, то вернись на эту страницу, сбрось пользовательский инстанс и сообщи нам об этом на github.", + "clear_cache.body": "все файлы из очереди обработки будут удалены и локальные фичи займут больше времени на загрузку. это действие мгновенное и необратимое.", + "safety.title": "важное предупреждение о безопасности" +} diff --git a/web/i18n/ru/donate.json b/web/i18n/ru/donate.json new file mode 100644 index 00000000..9b88340a --- /dev/null +++ b/web/i18n/ru/donate.json @@ -0,0 +1,29 @@ +{ + "card.once": "одноразовый донат", + "card.option.30": "обед для двоих", + "body.no_bullshit": "мы считаем, что интернет не должен быть страшным. поэтому в кобальте никогда не будет рекламы или другого вредоносного контента. это обещание, за которым мы стоим горой. всё, что мы делаем, создаётся с учётом конфиденциальности, доступности и простоты использования, что делает кобальт доступным для всех.", + "card.custom": "своя сумма (от $2)", + "card.processor": "через {{value}}", + "card.option.5": "чашка кофе", + "card.option.50": "10кг кошачьего корма", + "card.option.1599": "базовый макбук", + "card.option.4900": "10,000 яблок", + "share.title": "поделись кобальтом с другом", + "alternative.title": "альтернативные способы доната", + "alt.copy": "{{ value }}. адрес криптокошелька. нажми, чтобы скопировать.", + "alt.open": "{{ value }}. нажми, чтобы открыть.", + "body.motivation": "кобальт помогает продюсерам, преподавателям, видеомейкерам и многим другим заниматься тем, что они любят. это особый сервис, создающийся с любовью, а не ради прибыли.", + "body.keep_going": "если кобальт помог тебе, пожалуйста, подумай над тем, чтобы поддержать нашу работу! ты можешь поддержать нас донатом, либо поделившись кобальтом с другом. каждый донат очень ценится и помогает нам продолжать работу над кобальтом и другими проектами.", + "card.recurring": "регулярный донат", + "card.option.10": "большая пицца", + "card.option.15": "полный обед", + "card.custom.submit": "своя сумма", + "banner.title": "Поддержи безопасный\nи открытый Интернет", + "banner.subtitle": "поддержи imput или поделись\nкобальтом с другом", + "card.option.100": "один год доменов", + "card.option.200": "аэрогриль", + "card.option.500": "крутое офисное кресло", + "card.option.7398": "флагманский макбук", + "card.option.8629": "маленький земельный участок", + "card.option.9433": "джакузи класса люкс" +} diff --git a/web/i18n/ru/error.json b/web/i18n/ru/error.json new file mode 100644 index 00000000..a8f0d643 --- /dev/null +++ b/web/i18n/ru/error.json @@ -0,0 +1,8 @@ +{ + "pipeline.missing_response_data": "инстанс обработки не ответил с нужной информацией о файле, поэтому я не могу создать задачи для локальной обработки. попробуй ещё раз через несколько секунд и сообщи о проблеме, если она не исчезнет!", + "captcha_too_long": "cloudflare turnstile слишком долго проверяет, что ты не бот. попробуй ещё раз, но если снова появится эта ошибка, то можно попробовать: отключить странные расширения браузера, сменить сеть, использовать другой браузер или проверить устройство на наличие вредоносных программ.", + "import.invalid": "в этом файле нет совместимых настроек кобальта для импорта. ты уверен, что это тот файл?", + "tunnel.probe": "не удалось протестировать этот туннель. возможно, твой браузер или настройки сети блокируют доступ к одному из серверов кобальта. ты уверен, что у тебя нет каких-то странных расширений для браузера?", + "import.unknown": "не удалось загрузить данные из файла. возможно, он повреждён или не того формата. вот ошибка, которую я получил:\n\n{{ value }}", + "import.no_data": "из этого файла нечего загружать. ты уверен, что это тот файл?" +} diff --git a/web/i18n/ru/error/api.json b/web/i18n/ru/error/api.json new file mode 100644 index 00000000..535a100a --- /dev/null +++ b/web/i18n/ru/error/api.json @@ -0,0 +1,51 @@ +{ + "auth.jwt.invalid": "не удалось пройти аутентификацию с инстансом обработки, потому что токен доступа недействителен. попробуй ещё раз через пару секунд или перезагрузи страницу!", + "auth.turnstile.invalid": "не удалось пройти аутентификацию с инстансом обработки, потому что решение капчи недействительно. попробуй ещё раз через пару секунд или перезагрузи страницу!", + "auth.key.not_api_key": "для использования этого инстанса нужен ключ доступа, но его нет. добавь его в настройках!", + "auth.key.invalid": "ключ доступа недействителен. сбрось его в настройках инстанса и используй правильный!", + "auth.key.ua_not_allowed": "ты не можешь использовать этот ключ доступа с текущего юзер агента. попробуй другой клиент или устройство!", + "unreachable": "не удалось подключиться к инстансу обработки. проверь своё интернет-соединение и попробуй ещё раз!", + "rate_exceeded": "ты делаешь слишком много запросов. попробуй снова через {{ limit }}с.", + "capacity": "кобальт сейчас перегружен и не может обработать твой запрос. попробуй ещё раз через пару секунд!", + "service.unsupported": "этот сервис ещё не поддерживается. ты уверен, что вставил правильную ссылку?", + "service.audio_not_supported": "этот сервис не поддерживает извлечение аудио. попробуй ссылку с другого сервиса!", + "link.invalid": "твоя ссылка недействительна или этот сервис ещё не поддерживается. ты точно вставил правильную ссылку?", + "fetch.fail": "что-то пошло не так при получении инфы из {{ service }}, и я ничего не смог для тебя достать. если эта проблема не исчезнет, пожалуйста, сообщи о ней!", + "auth.jwt.missing": "не удалось пройти аутентификацию с инстансом обработки, потому что отсутствует токен доступа. попробуй ещё раз через пару секунд или перезагрузи страницу!", + "auth.key.missing": "для использования этого инстанса нужен ключ доступа, но его нет. добавь его в настройках!", + "generic": "что-то пошло не так, и я не смог ничего найти для тебя. попробуй ещё раз через пару секунд. если проблема останется, пожалуйста, сообщи об этом!", + "auth.turnstile.missing": "не удалось пройти аутентификацию с инстансом обработки, потому что отсутствует решение капчи. попробуй ещё раз через пару секунд или перезагрузи страницу!", + "unknown_response": "не удалось прочитать ответ от инстанса обработки. скорее всего, причина в том, что веб-приложение устарело. перезагрузи его и попробуй снова!", + "auth.key.not_found": "использованный тобой ключ доступа не найден. ты уверен, что у этого инстанса есть твой ключ?", + "invalid_body": "не удалось отправить запрос на инстанс обработки. скорее всего, причина в том, что веб-приложение устарело. перезагрузи его и попробуй снова!", + "auth.key.invalid_ip": "не удалось распарсить твой ip-адрес. что-то пошло совсем не так, пожалуйста, сообщи об этой ошибке!", + "auth.key.ip_not_allowed": "ты не можешь использовать этот ключ доступа с текущего ip-адреса. попробуй другой инстанс или сеть!", + "timed_out": "инстанс обработки слишком долго не отвечал. возможно, он сейчас перегружен, попробуй ещё раз через пару секунд!", + "service.disabled": "этот сервис обычно поддерживается кобальтом, но он отключён на этом инстансе. попробуй ссылку с другого сервиса!", + "link.unsupported": "{{ service }} поддерживается, но я не смог распознать твою ссылку. ты точно вставил правильную?", + "fetch.critical": "модуль {{ service }} вернул ошибку, которую я не узнаю. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!", + "content.too_long": "запрошенное медиа слишком длинное. лимит длительности на этом инстансе — {{ limit }}мин. попробуй что-нибудь покороче!", + "content.video.unavailable": "я не могу получить доступ к этому видео. оно может быть ограничено со стороны {{ service }}. попробуй другую ссылку!", + "content.video.private": "это видео приватное, поэтому я не могу получить к нему доступ. измени его видимость или попробуй другое!", + "content.video.region": "это видео ограничено по региону, а инстанс обработки находится в другом месте. попробуй другую ссылку!", + "content.paid": "этот контент требует покупки. кобальт не может скачивать платный контент. попробуй другую ссылку!", + "content.post.private": "не удалось получить инфу об этом посте, потому что он от закрытого аккаунта. попробуй другую ссылку!", + "youtube.token_expired": "не удалось получить это видео, потому что токен youtube истёк и не был обновлён. попробуй ещё раз через пару секунд, но если так и не заработает, пожалуйста, сообщи об этой проблеме!", + "youtube.no_hls_streams": "не удалось найти ни одного подходящего HLS-потока для этого видео. попробуй скачать его без HLS!", + "youtube.api_error": "youtube что-то обновил в своём api, и я не смог получить инфу об этом видео. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!", + "youtube.drm": "это youtube-видео защищено widevine DRM, так что я не могу его скачать. попробуй другую ссылку!", + "fetch.rate": "{{ service }} ограничил частоту запросов от инстанса обработки. попробуй ещё раз через пару секунд!", + "youtube.temporary_disabled": "скачивание с youtube временно отключено из-за ограничений со стороны youtube. мы уже ищем способы их обойти.\n\nприносим извинения за неудобства и делаем всё возможное, чтобы восстановить эту функциональность. следи за обновлениями в соцсетях или на github!", + "content.video.age": "это видео ограничено по возрасту, поэтому я не могу получить его анонимно. попробуй ещё раз или попробуй другую ссылку!", + "content.region": "этот контент ограничен по региону, а инстанс обработки находится в другом месте. попробуй другую ссылку!", + "youtube.no_matching_format": "youtube не вернул ни одного подходящего формата. возможно, кобальт их не поддерживает или же они перекодируются на стороне youtube. попробуй ещё раз чуть позже, а если проблема останется, сообщи о ней!", + "youtube.no_session_tokens": "не удалось получить необходимые токены сессии для ютуба. это может быть вызвано ограничением со стороны ютуба. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!", + "youtube.decipher": "youtube обновил свой алгоритм расшифровки, и из-за этого мне не удалось получить информацию о видео. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!", + "fetch.short_link": "не удалось получить инфу по короткой ссылке. ты уверен, что она работает? если да, а ты всё равно видишь эту ошибку, пожалуйста, сообщи о ней!", + "fetch.empty": "не смог найти медиа, которое я мог бы скачать для тебя. ты уверен, что вставил правильную ссылку?", + "content.post.age": "этот пост ограничен по возрасту, поэтому я не могу получить его анонимно. попробуй ещё раз или попробуй другую ссылку!", + "youtube.login": "не удалось получить это видео, потому что youtube попросил доказать, что инстанс обработки — не бот. попробуй ещё раз через пару секунд, но если так и не заработает, пожалуйста, сообщи об этой проблеме!", + "content.video.live": "это видео сейчас идёт в прямом эфире, поэтому я ещё не могу его скачать. подожди, пока стрим закончится, и попробуй снова!", + "content.post.unavailable": "не удалось ничего найти об этом посте. его видимость может быть ограничена или он может не существовать. убедись, что твоя ссылка работает, и попробуй снова через пару секунд!", + "fetch.critical.core": "один из основных модулей выдал ошибку, которую я не узнаю. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!" +} diff --git a/web/i18n/ru/error/queue.json b/web/i18n/ru/error/queue.json new file mode 100644 index 00000000..5c3df793 --- /dev/null +++ b/web/i18n/ru/error/queue.json @@ -0,0 +1,19 @@ +{ + "fetch.no_file_reader": "не смог записать файл в кэш", + "worker_didnt_start": "не смог запустить воркер обработки", + "ffmpeg.probe_failed": "не удалось проверить этот файл, возможно, он повреждён или не поддерживается", + "fetch.network_error": "скачивание было прервано из-за проблем с сетью", + "no_final_file": "финальный файл пропал", + "fetch.corrupted_file": "файл был скачан не полностью, попробуй ещё раз", + "fetch.crashed": "воркер скачивания вылетел, смотри детали в консоли", + "fetch.bad_response": "не смог получить туннель файла", + "fetch.empty_tunnel": "туннель файла пустой, попробуй ещё раз через несколько минут", + "ffmpeg.no_input_type": "тип этого файла не поддерживается", + "ffmpeg.crashed": "воркер ffmpeg вылетел, смотри детали в консоли", + "ffmpeg.no_input_format": "формат этого файла не поддерживается", + "ffmpeg.out_of_memory": "не хватает памяти, не могу продолжить", + "ffmpeg.no_render": "рендер ffmpeg пустой, произошло что-то очень странное", + "ffmpeg.no_args": "воркер ffmpeg не получил нужные аргументы", + "generic_error": "воркер обработки вылетел, смотри детали в консоли", + "ffmpeg.no_audio_channel": "у этого видео нет аудиодорожки, ничего нельзя сделать" +} diff --git a/web/i18n/ru/general.json b/web/i18n/ru/general.json index 90cbfef5..d10e39a2 100644 --- a/web/i18n/ru/general.json +++ b/web/i18n/ru/general.json @@ -2,6 +2,5 @@ "cobalt": "кобальт", "meowbalt": "мяубальт", "beta": "бета", - - "embed.description": "сохраняй то, что любишь: без рекламы, трекеров и прочей чепухи. кобальт создан с любовью, а не с целью заработать." + "embed.description": "кобальт помогает тебе сохранять то, что ты любишь, без рекламы, трекеров и прочей ерунды. просто вставь ссылку!" } diff --git a/web/i18n/ru/notification.json b/web/i18n/ru/notification.json new file mode 100644 index 00000000..14822cf3 --- /dev/null +++ b/web/i18n/ru/notification.json @@ -0,0 +1,4 @@ +{ + "update.title": "доступно обновление!", + "update.subtext": "нажми, чтобы обновить" +} diff --git a/web/i18n/ru/queue.json b/web/i18n/ru/queue.json new file mode 100644 index 00000000..62a8102a --- /dev/null +++ b/web/i18n/ru/queue.json @@ -0,0 +1,13 @@ +{ + "state.waiting": "в очереди", + "state.starting.fetch": "начинаю скачивание", + "state.running.remux": "ремуксирую", + "state.retrying": "повторяю", + "state.starting.encode": "начинаю транскодирование", + "title": "очередь обработки", + "state.starting": "начинаю", + "state.starting.remux": "начинаю ремуксинг", + "state.running.fetch": "скачиваю", + "state.running.encode": "транскодирую", + "stub": "тут пока что ничего нет, только мы вдвоём.\nпопробуй скачать что-нибудь!" +} diff --git a/web/i18n/ru/receiver.json b/web/i18n/ru/receiver.json new file mode 100644 index 00000000..2f808fac --- /dev/null +++ b/web/i18n/ru/receiver.json @@ -0,0 +1,7 @@ +{ + "accept": "поддерживаемые форматы: {{ formats }}.", + "title": "перетащи или выбери файл", + "title.drop": "скинь файл сюда!", + "title.multiple": "перетащи или выбери файлы", + "title.drop.multiple": "скинь файлы сюда!" +} diff --git a/web/i18n/ru/remux.json b/web/i18n/ru/remux.json new file mode 100644 index 00000000..1ee7fa6d --- /dev/null +++ b/web/i18n/ru/remux.json @@ -0,0 +1,8 @@ +{ + "bullet.purpose.description": "ремукс исправляет любые проблемы с файлом, например, отсутствие информации о времени. он помогает повысить совместимость со старыми программами, такими как vegas pro и windows media player.", + "bullet.purpose.title": "что делает ремукс?", + "bullet.explainer.title": "как он работает?", + "bullet.explainer.description": "ремукс берёт существующие данные кодека и копирует их в новый медиаконтейнер. это происходит без потери качества, так как медиаданные не перекодируются.", + "bullet.privacy.title": "локальная обработка", + "bullet.privacy.description": "кобальт ремуксирует файлы локально. файлы никогда не покидают твоё устройство, поэтому обработка происходит практически мгновенно." +} diff --git a/web/i18n/ru/save.json b/web/i18n/ru/save.json index ce64917a..60bb2998 100644 --- a/web/i18n/ru/save.json +++ b/web/i18n/ru/save.json @@ -10,5 +10,15 @@ "services.title": "поддерживаемые сервисы", "services.title_show": "показать поддерживаемые сервисы", "services.title_hide": "скрыть поддерживаемые сервисы", - "services.disclaimer": "кобальт не аффилирован ни с одним из перечисленных выше сервисов.\n\nдеятельность meta platforms (владельца facebook и instagram) запрещена на территории РФ и признана экстремистской." + "services.disclaimer": "кобальт не аффилирован ни с одним из перечисленных выше сервисов.\n\nдеятельность meta platforms (владельца facebook и instagram) запрещена на территории РФ и признана экстремистской.", + "tutorial.step.1": "добавь команды-компаньоны:", + "tutorial.step.2": "нажми кнопку \"поделиться\" в диалоге сохранения кобальта.", + "tutorial.step.3": "выбери нужную команду в окне обмена.", + "tutorial.shortcut.photos": "в фото", + "tutorial.shortcut.files": "в файлы", + "tutorial.title": "как сохранить на ios?", + "tutorial.intro": "чтобы удобно сохранять файлы на ios, придётся использовать команду siri в меню обмена.", + "tutorial.outro": "эти команды siri будут работать только из приложения кобальта, использовать их из других приложений не получится.", + "tooltip.captcha": "cloudflare turnstile проверяет, что ты не бот. подожди, пожалуйста!", + "label.community_instance": "инстанс сообщества" } diff --git a/web/i18n/ru/settings.json b/web/i18n/ru/settings.json new file mode 100644 index 00000000..9794aa47 --- /dev/null +++ b/web/i18n/ru/settings.json @@ -0,0 +1,129 @@ +{ + "theme.auto": "авто", + "theme.light": "светлая", + "audio.bitrate.kbps": "кб/с", + "theme.dark": "тёмная", + "audio.youtube.dub": "звуковая дорожка youtube", + "video.quality.max": "8k+", + "page.video": "видео", + "page.audio": "аудио", + "video.quality.1440": "1440p", + "video.quality.1080": "1080p", + "video.quality.720": "720p", + "video.quality.480": "480p", + "video.quality.360": "360p", + "video.quality.240": "240p", + "video.quality.144": "144p", + "metadata.file": "метаданные файла", + "saving.title": "метод сохранения", + "saving.ask": "спросить", + "saving.download": "скачать", + "saving.share": "поделиться", + "saving.copy": "скопировать", + "language": "язык", + "language.preferred.title": "предпочитаемый язык", + "privacy.analytics": "анонимная аналитика трафика", + "audio.tiktok.original.title": "скачивать оригинальный звук", + "privacy.tunnel": "туннелирование", + "privacy.tunnel.title": "всегда туннелировать файлы", + "audio.format.mp3": "mp3", + "audio.format.ogg": "ogg", + "audio.format.wav": "wav", + "audio.format.opus": "opus", + "page.privacy": "приватность", + "theme": "тема", + "video.quality": "качество видео", + "video.twitter.gif": "twitter/x", + "video.quality.2160": "4k", + "audio.format": "формат аудио", + "audio.bitrate": "битрейт аудио", + "audio.tiktok.original": "tiktok", + "metadata.disable.title": "отключить метаданные", + "language.auto.title": "автоматический выбор", + "metadata.disable.description": "название, исполнитель и другая информация не будут добавлены в файл.", + "language.preferred.description": "этот язык будет использоваться когда автоматический выбор отключен. любой непереведённый текст будет отображаться на английском языке.\n\nмы используем переводы, предоставленные сообществом. они могут быть неточными или неполными.", + "audio.youtube.dub.description": "cobalt будет использовать дублированную аудиодорожку для выбранного языка, если она доступна. в противном случае будет использоваться оригинальная.", + "language.auto.description": "если доступен перевод, то кобальт будет использовать язык твоего браузера. в ином случае будет использоваться английский.", + "theme.description": "авто тема переключается между светлой и тёмной темой в зависимости от системной темы.", + "page.debug": "инфа для гиков", + "page.appearance": "внешний вид", + "page.instances": "инстансы", + "page.advanced": "продвинутые", + "page.accessibility": "общедоступность", + "page.metadata": "метаданные", + "page.local": "локальная обработка", + "video.youtube.codec": "предпочитаемый кодек для youtube", + "audio.youtube.dub.title": "предпочитаемый язык озвучки", + "metadata.filename.basic": "базовый", + "video.twitter.gif.title": "конвертировать зацикленные видео в GIF", + "metadata.filename.description": "стиль названий файлов используется только для файлов, туннелированных через кобальт. некоторые сервисы поддерживают только классический стиль.", + "youtube.dub.original": "оригинальный", + "metadata.filename.pretty": "красивый", + "metadata.filename.nerdy": "занудный", + "audio.tiktok.original.description": "кобальт будет скачивать оригинальный звук из видео без каких-либо изменений от автора поста.", + "metadata.filename": "стиль названий файлов", + "metadata.filename.classic": "классический", + "video.twitter.gif.description": "GIF конвертация неэффективна, финальный файл может быть огромным и в плохом качестве.", + "audio.youtube.better_audio.title": "предпочитать лучшее качество", + "audio.format.description": "все форматы кроме \"лучшего\" конвертируются из исходного формата, поэтому возможна небольшая потеря качества. когда выбран \"лучший\" формат, аудио остаётся в оригинальном формате, если это возможно.", + "audio.youtube.better_audio.description": "кобальт будет пытаться выбрать самое качественное аудио в режиме скачивания аудио. оно может быть недоступно в зависимости от ответа youtube, текущей нагрузки и состояния сервера. на кастомных инстансах эта опция может не поддерживаться.", + "audio.youtube.better_audio": "качество аудио с youtube", + "video.quality.description": "если предпочитаемое качество недоступно, то выбирается следующий лучший вариант.", + "video.youtube.codec.description": "h264: наилучшая совместимость, среднее качество. максимальное качество — 1080p.\nav1: наилучшее качество и сжатие. поддерживает 8k и HDR.\nvp9: то же качество, что и у av1, но файл в ~2x больше. поддерживает 4k & HDR.\n\nav1 и vp9 не очень широко поддерживаются, возможно придётся использовать дополнительное ПО для их проигрывания/обработки. кобальт выбирает следующий лучший кодек, если предпочитаемый недоступен.", + "audio.bitrate.description": "битрейт применяется только при конвертации аудио в формат с потерями. кобальт не может улучшить качество исходного аудио, поэтому выбор битрейта выше 128 кб/с может увеличить размер файла без заметной разницы в звуке. воспринимаемое качество может различаться в зависимости от формата.", + "video.h265": "high efficiency video codec", + "video.h265.title": "использовать h265 для видео", + "video.h265.description": "позволяет скачивать видео с tiktok и xiaohongshu в более высоком качестве, но с потерей совместимости.", + "video.youtube.hls": "форматы hls для youtube", + "video.youtube.hls.description": "в этом режиме доступны только кодеки h264 и vp9. оригинальный аудио кодек aac перекодируется для совместимости, поэтому качество аудио может быть хуже чем у варианта без HLS.\n\nэта функция экспериментальна, поэтому может быть убрана или изменена в будущем.", + "audio.format.best": "лучший", + "video.youtube.hls.title": "предпочитать hls для видео и аудио", + "metadata.filename.preview.video": "Название Видео - Автор Видео", + "metadata.filename.preview.audio": "Название Аудио - Автор Аудио", + "saving.description": "предпочтительный способ сохранения файла или ссылки с кобальта. если предпочитаемый метод недоступен или что-то пойдёт не так, кобальт спросит тебя как поступить.", + "accessibility.transparency.description": "уменьшает прозрачность поверхностей и выключает эффекты размытия. также может улучшить работу интерфейса на менее мощных устройствах.", + "accessibility.transparency.title": "уменьшить визуальную прозрачность", + "accessibility.visual": "интерфейс", + "accessibility.haptics": "вибрация", + "accessibility.behavior": "поведение", + "accessibility.auto_queue.description": "очередь обработки не будет открываться автоматически при добавлении новой задачи. прогресс всё равно будет отображаться, и ты всё равно сможешь открыть её вручную.", + "privacy.analytics.learnmore": "узнай больше о преданности plausible к приватности.", + "accessibility.motion.description": "анимации и переходы будут отключены, когда это возможно.", + "accessibility.haptics.title": "отключить вибрацию", + "accessibility.haptics.description": "вся вибрация будет отключена.", + "accessibility.auto_queue.title": "не открывать очередь обработки", + "privacy.analytics.description": "анонимная аналитика трафика нужна, чтобы знать приблизительное количество активных пользователей кобальта. идентифицирующая информация о тебе никогда не сохраняется. все обрабатываемые данные анонимизированы и агрегированы.\n\nмы используем собственный инстанс plausible, который не использует куки и полностью соответствует требованиям GDPR, CCPA и PECR.", + "privacy.tunnel.description": "cobalt скроет твой ip адрес, информацию о браузере и обойдёт местные сетевые ограничения. когда включено, у всех файлов будут читаемые названия вместо абракадабры.", + "accessibility.motion.title": "уменьшить движение", + "privacy.analytics.title": "не участвовать в аналитике", + "advanced.debug": "отладка", + "advanced.debug.description": "даёт доступ к странице с различной информацией, которая может быть полезна для отладки. никак не меняет поведение кобальта.", + "advanced.debug.title": "включить функции для зануд", + "processing.community": "инстансы сообщества", + "processing.enable_custom.description": "кобальт будет использовать сторонний инстанс обработки, если ты так решишь. несмотря на то, что у кобальта есть некоторые меры безопасности, мы не несём ответственности за любой ущерб, причинённый сторонним инстансом, так как мы его не контролируем.\n\nбудь осторожен с тем, какие инстансы ты используешь, и убедись, что их хостят люди, которым ты доверяешь.", + "processing.enable_custom.title": "использовать сторонний инстанс", + "local.saving": "локальная обработка медиа", + "local.saving.description": "при скачивании медиа, ремуксинг и транскодирование будут выполняться на устройстве, а не в облаке. ты увидишь подробный прогресс в очереди обработки.\n\nникогда: локальная обработка не будет использоваться. инстансы обработки могут принудительно включать эту функцию, поэтому эта опция может не иметь эффекта.\nиногда: медиафайлы, требующие дополнительной обработки, будут загружаться через очередь обработки, но остальные медиафайлы будут загружаться менеджером загрузок твоего браузера.\nвсегда: все медиафайлы всегда будут проксироваться и загружаться через очередь обработки.\n\nэксклюзивные функции на устройстве не зависят от этой настройки, они всегда работают локально.", + "advanced.settings_data": "данные настроек", + "local.webcodecs.description": "при декодировании или кодировании файлов кобальт будет пытаться использовать webcodecs. эта функция позволяет обрабатывать медиафайлы с ускорением на GPU, так что всё декодирование и кодирование будет намного быстрее.\n\nдоступность и стабильность этой функции зависят от возможностей твоего устройства и браузера. что-то может сломаться или работать некорректно.", + "processing.access_key": "ключ доступа к инстансу", + "advanced.local_storage": "локальное хранилище", + "local.webcodecs": "webcodecs", + "local.webcodecs.title": "использовать webcodecs для локальной обработки", + "processing.access_key.title": "использовать ключ доступа", + "processing.custom_instance.input.alt_text": "домен стороннего инстанса", + "tabs": "навигация", + "tabs.hide_remux": "скрыть страницу ремукса", + "tabs.hide_remux.description": "если ты не пользуешься ремуксом, то его можно скрыть из панели навигации.", + "processing.access_key.description": "кобальт будет использовать этот ключ для запросов к инстансу обработки вместо других методов аутентификации. убедись, что инстанс поддерживает api ключи!", + "processing.access_key.input.alt_text": "ключ доступа u-u-i-d", + "video.youtube.container": "контейнер файла для youtube", + "video.youtube.container.description": "когда выбран \"авто\" контейнер, кобальт автоматически подберёт оптимальный контейнер в зависимости от выбранного кодека: mp4 для h264; webm для vp9/av1.", + "subtitles.description": "кобальт добавит субтитры к скачанному файлу на предпочитаемом языке, если они доступны.\n\nнекоторые сервисы не имеют выбора языка, и в таком случае кобальт добавит единственную доступную дорожку субтитров, если выбран любой язык.", + "subtitles": "субтитры", + "subtitles.title": "язык субтитров", + "subtitles.none": "никакой", + "local.saving.disabled": "никогда", + "local.saving.preferred": "иногда", + "local.saving.forced": "всегда" +} diff --git a/web/i18n/ru/tabs.json b/web/i18n/ru/tabs.json index 0b93cc7f..afe0d693 100644 --- a/web/i18n/ru/tabs.json +++ b/web/i18n/ru/tabs.json @@ -3,6 +3,6 @@ "settings": "настройки", "updates": "новости", "donate": "донаты", - "about": "инфа", + "about": "инфо", "remux": "ремукс" } diff --git a/web/i18n/ru/updates.json b/web/i18n/ru/updates.json new file mode 100644 index 00000000..f6ab7698 --- /dev/null +++ b/web/i18n/ru/updates.json @@ -0,0 +1,4 @@ +{ + "button.next": "перейти к предыдущему обновлению ({{ value }})", + "button.previous": "перейти к следующему обновлению ({{ value }})" +} From 4d2c8b0a8c78a70cd6ffc6cf10540e9115baa093 Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 1 Jul 2025 00:19:29 +0600 Subject: [PATCH 112/138] web/package: bump version to 11.2.2 --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index 10d05a2d..d31e9590 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@imput/cobalt-web", - "version": "11.2.1", + "version": "11.2.2", "type": "module", "private": true, "scripts": { From 9b3ebe90c56509b69b208a85bfd5ac51af5886c0 Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 1 Jul 2025 00:56:04 +0600 Subject: [PATCH 113/138] api/language-codes: remove region part of the language code and convert language codes if they're not 3 characters long --- api/src/misc/language-codes.js | 1 + api/src/processing/match-action.js | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/api/src/misc/language-codes.js b/api/src/misc/language-codes.js index c18006b5..804e3e55 100644 --- a/api/src/misc/language-codes.js +++ b/api/src/misc/language-codes.js @@ -49,5 +49,6 @@ const maps = { } export const convertLanguageCode = (code) => { + code = code.split("-")[0].split("_")[0]; return maps[code.length]?.[code.toLowerCase()] || null; } diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index 555705e8..6d1b0254 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -263,8 +263,9 @@ export default function({ // extractors usually return ISO 639-1 language codes, // but video players expect ISO 639-2, so we convert them here - if (defaultParams.fileMetadata?.sublanguage?.length === 2) { - const code = convertLanguageCode(defaultParams.fileMetadata.sublanguage); + const sublanguage = defaultParams.fileMetadata?.sublanguage; + if (sublanguage && sublanguage.length !== 3) { + const code = convertLanguageCode(sublanguage); if (code) { defaultParams.fileMetadata.sublanguage = code; } else { From 5b12622b66105991b8263955cadba3da920e73ca Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 1 Jul 2025 10:13:28 +0600 Subject: [PATCH 114/138] web/FilenamePreview: fix unlocalized strings oops --- web/i18n/en/settings.json | 2 ++ web/i18n/ru/settings.json | 2 ++ web/src/components/settings/FilenamePreview.svelte | 4 ++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/web/i18n/en/settings.json b/web/i18n/en/settings.json index dac6b62a..5caf67f5 100644 --- a/web/i18n/en/settings.json +++ b/web/i18n/en/settings.json @@ -85,6 +85,8 @@ "metadata.filename.preview.video": "Video Title - Video Author", "metadata.filename.preview.audio": "Audio Title - Audio Author", + "filename.preview_desc.video": "video file preview", + "filename.preview_desc.audio": "audio file preview", "metadata.file": "file metadata", "metadata.disable.title": "disable file metadata", diff --git a/web/i18n/ru/settings.json b/web/i18n/ru/settings.json index 9794aa47..937c4b54 100644 --- a/web/i18n/ru/settings.json +++ b/web/i18n/ru/settings.json @@ -80,6 +80,8 @@ "video.youtube.hls.title": "предпочитать hls для видео и аудио", "metadata.filename.preview.video": "Название Видео - Автор Видео", "metadata.filename.preview.audio": "Название Аудио - Автор Аудио", + "filename.preview_desc.video": "превью видео файла", + "filename.preview_desc.audio": "превью аудио файла", "saving.description": "предпочтительный способ сохранения файла или ссылки с кобальта. если предпочитаемый метод недоступен или что-то пойдёт не так, кобальт спросит тебя как поступить.", "accessibility.transparency.description": "уменьшает прозрачность поверхностей и выключает эффекты размытия. также может улучшить работу интерфейса на менее мощных устройствах.", "accessibility.transparency.title": "уменьшить визуальную прозрачность", diff --git a/web/src/components/settings/FilenamePreview.svelte b/web/src/components/settings/FilenamePreview.svelte index 4af25be9..35a7612f 100644 --- a/web/src/components/settings/FilenamePreview.svelte +++ b/web/src/components/settings/FilenamePreview.svelte @@ -75,7 +75,7 @@
{`${videoFilePreview}.${youtubeVideoExt}`}
-
video file preview
+
{$t("settings.filename.preview_desc.video")}
@@ -84,7 +84,7 @@
{`${audioFilePreview}.${audioFormat}`}
-
audio file preview
+
{$t("settings.filename.preview_desc.audio")}
From 810e0a865c7d6be7d6f9aea7f879b92a7775dd68 Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 1 Jul 2025 16:15:12 +0600 Subject: [PATCH 115/138] api/package: replace youtubei.js with a fork, update undici --- api/package.json | 4 +-- api/src/core/env.js | 2 +- api/src/processing/services/youtube.js | 2 +- pnpm-lock.yaml | 40 +++++++++++++++----------- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/api/package.json b/api/package.json index 1611247d..65926020 100644 --- a/api/package.json +++ b/api/package.json @@ -26,6 +26,7 @@ "@datastructures-js/priority-queue": "^6.3.1", "@imput/psl": "^2.0.4", "@imput/version-info": "workspace:^", + "@imput/youtubei.js": "^14.0.0", "content-disposition-header": "0.6.0", "cors": "^2.8.5", "dotenv": "^16.0.1", @@ -37,9 +38,8 @@ "mime": "^4.0.4", "nanoid": "^5.0.9", "set-cookie-parser": "2.6.0", - "undici": "^5.19.1", + "undici": "^6.21.3", "url-pattern": "1.0.3", - "youtubei.js": "^14.0.0", "zod": "^3.23.8" }, "optionalDependencies": { diff --git a/api/src/core/env.js b/api/src/core/env.js index f26b3986..5b05ad76 100644 --- a/api/src/core/env.js +++ b/api/src/core/env.js @@ -1,4 +1,4 @@ -import { Constants } from "youtubei.js"; +import { Constants } from "@imput/youtubei.js"; import { services } from "../processing/service-config.js"; import { updateEnv, canonicalEnv, env as currentEnv } from "../config.js"; diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js index 55caa835..dbf93bb8 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -1,7 +1,7 @@ import HLS from "hls-parser"; import { fetch } from "undici"; -import { Innertube, Session } from "youtubei.js"; +import { Innertube, Session } from "@imput/youtubei.js"; import { env } from "../../config.js"; import { getCookie } from "../cookie/manager.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d67cc68..ef027b1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ importers: '@imput/version-info': specifier: workspace:^ version: link:../packages/version-info + '@imput/youtubei.js': + specifier: ^14.0.0 + version: 14.0.0 content-disposition-header: specifier: 0.6.0 version: 0.6.0 @@ -53,14 +56,11 @@ importers: specifier: 2.6.0 version: 2.6.0 undici: - specifier: ^5.19.1 - version: 5.28.4 + specifier: ^6.21.3 + version: 6.21.3 url-pattern: specifier: 1.0.3 version: 1.0.3 - youtubei.js: - specifier: ^14.0.0 - version: 14.0.0 zod: specifier: ^3.23.8 version: 3.23.8 @@ -563,6 +563,9 @@ packages: '@imput/psl@2.0.4': resolution: {integrity: sha512-vuy76JX78/DnJegLuJoLpMmw11JTA/9HvlIADg/f8dDVXyxbh0jnObL0q13h+WvlBO4Gk26Pu8sUa7/h0JGQig==} + '@imput/youtubei.js@14.0.0': + resolution: {integrity: sha512-YvTnh53URPlzsmMzqF/DFHZyR9HrpgoWYHzEOklx5OCkwk1/0F/CrO9gqArXw/1oI6GjaTS2CqBd1CzyFZB07A==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -2080,6 +2083,10 @@ packages: resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} engines: {node: '>=14.0'} + undici@6.21.3: + resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} + engines: {node: '>=18.17'} + unist-util-stringify-position@2.0.3: resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==} @@ -2181,9 +2188,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - youtubei.js@14.0.0: - resolution: {integrity: sha512-KAFttOw+9fwwBUvBc1T7KzMNBLczDOuN/dfote8BA9CABxgx8MPgV+vZWlowdDB6DnHjSUYppv+xvJ4VNBLK9A==} - zimmerframe@1.1.2: resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} @@ -2396,7 +2400,8 @@ snapshots: dependencies: levn: 0.4.1 - '@fastify/busboy@2.1.1': {} + '@fastify/busboy@2.1.1': + optional: true '@fontsource/ibm-plex-mono@5.0.13': {} @@ -2423,6 +2428,13 @@ snapshots: dependencies: punycode: 2.3.1 + '@imput/youtubei.js@14.0.0': + dependencies: + '@bufbuild/protobuf': 2.2.5 + jintr: 3.3.1 + tslib: 2.6.3 + undici: 6.21.3 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3960,6 +3972,9 @@ snapshots: undici@5.28.4: dependencies: '@fastify/busboy': 2.1.1 + optional: true + + undici@6.21.3: {} unist-util-stringify-position@2.0.3: dependencies: @@ -4035,13 +4050,6 @@ snapshots: yocto-queue@0.1.0: {} - youtubei.js@14.0.0: - dependencies: - '@bufbuild/protobuf': 2.2.5 - jintr: 3.3.1 - tslib: 2.6.3 - undici: 5.28.4 - zimmerframe@1.1.2: {} zod@3.23.8: {} From f3992fbe337c5506ee54475c07c65180a3c5177f Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 1 Jul 2025 16:32:39 +0600 Subject: [PATCH 116/138] api/language-codes: prevent errors if code is undefined --- api/src/misc/language-codes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/misc/language-codes.js b/api/src/misc/language-codes.js index 804e3e55..1f692601 100644 --- a/api/src/misc/language-codes.js +++ b/api/src/misc/language-codes.js @@ -49,6 +49,6 @@ const maps = { } export const convertLanguageCode = (code) => { - code = code.split("-")[0].split("_")[0]; + code = code?.split("-")[0]?.split("_")[0] || ""; return maps[code.length]?.[code.toLowerCase()] || null; } From 23064f83001a63c143e8d5955c6e07d9ec35dc20 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 4 Jul 2025 13:55:26 +0600 Subject: [PATCH 117/138] web/changelogs/11.2: add info about youtube's unavailability --- web/changelogs/11.2.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/changelogs/11.2.md b/web/changelogs/11.2.md index 34629289..3cc6ed29 100644 --- a/web/changelogs/11.2.md +++ b/web/changelogs/11.2.md @@ -6,7 +6,7 @@ banner: alt: "meowth plush in a forest looking at the rising sun between the trees." --- -it's summertime! even though it's been rainy for us lately, the sun is right on the horizon, just like this cobalt update. we improved local processing, added long-awaited features, and improved a ton of other stuff. **downloading from youtube is back, btw**. +it's summertime! even though it's been rainy for us lately, the sun is right on the horizon, just like this cobalt update. we improved local processing, added long-awaited features, and improved a ton of other stuff. here's what's new since 11.0: @@ -31,6 +31,8 @@ downloading from youtube on the main instance is restored! sorry that it took a hopefully it'll last for a while, but we think downloading from youtube will get significantly more annoying/complex in next few weeks-months. **right now is the best time to download everything you've been putting off**, either with cobalt or other tools. +**update**: unfortunately it did not last, youtube is unavailable on the main instance again. we will try one more way soon and update this changelog and post about it on socials accordingly. + we're not trying to scare you; it's our educated guess based on what youtube has been doing lately: - roll out of SABR & related limitations for more clients. SABR is Server ABR, Google's proprietary HLS alternative, controlled by the server. - growing potoken enforcement. From 926e9b7231e4ff1e16788782754731b2bb0db61a Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 4 Jul 2025 13:57:10 +0600 Subject: [PATCH 118/138] api/package: bump version to 11.2.1 --- api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/package.json b/api/package.json index 65926020..7d02a08b 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "@imput/cobalt-api", "description": "save what you love", - "version": "11.2", + "version": "11.2.1", "author": "imput", "exports": "./src/cobalt.js", "type": "module", From 773ed026b87182d1071652be86a4f964cba1d967 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 4 Jul 2025 13:57:17 +0600 Subject: [PATCH 119/138] web/package: bump version to 11.2.3 --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index d31e9590..ad470d87 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@imput/cobalt-web", - "version": "11.2.2", + "version": "11.2.3", "type": "module", "private": true, "scripts": { From b4290ecf30f17785735c21baf37403209297bcf7 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 4 Jul 2025 15:51:59 +0600 Subject: [PATCH 120/138] api/vimeo: use bearer, update headers, better error handling --- api/src/processing/services/vimeo.js | 68 +++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/api/src/processing/services/vimeo.js b/api/src/processing/services/vimeo.js index 7a65f17a..7be3a4b6 100644 --- a/api/src/processing/services/vimeo.js +++ b/api/src/processing/services/vimeo.js @@ -15,7 +15,46 @@ const resolutionMatch = { "426": 240 } -const requestApiInfo = (videoId, password) => { +const genericHeaders = { + Accept: 'application/vnd.vimeo.*+json; version=3.4.10', + 'User-Agent': 'Vimeo/11.13.0 (com.vimeo; build:250619.102023.0; iOS 18.5.0) Alamofire/5.9.0 VimeoNetworking/5.0.0', + Authorization: 'Basic MTMxNzViY2Y0NDE0YTQ5YzhjZTc0YmU0NjVjNDQxYzNkYWVjOWRlOTpHKzRvMmgzVUh4UkxjdU5FRW80cDNDbDhDWGR5dVJLNUJZZ055dHBHTTB4V1VzaG41bEx1a2hiN0NWYWNUcldSSW53dzRUdFRYZlJEZmFoTTArOTBUZkJHS3R4V2llYU04Qnl1bERSWWxUdXRidjNqR2J4SHFpVmtFSUcyRktuQw==', + 'Accept-Language': 'en-US,en;q=0.9', +} + +let bearer = ''; + +const getBearer = async (refresh = false) => { + if (bearer && !refresh) return bearer; + + const oauthResponse = await fetch( + `https://api.vimeo.com/oauth/authorize/client?sizes=216,288,300,360,640,960,1280,1920&cdm_type=fairplay`, + { + method: 'POST', + body: JSON.stringify({ + scope: 'public private purchased create edit delete interact upload stats', + grant_type: 'client_credentials', + // device_identifier is a long ass base64 string of seemingly + // random data, but it doesn't seem to be required, so we just omit it lol + device_identifier: '', + }), + headers: { + ...genericHeaders, + 'Content-Type': 'application/json', + } + } + ) + .then(a => a.json()) + .catch(() => {}); + + if (!oauthResponse || !oauthResponse.access_token) { + return; + } + + return bearer = oauthResponse.access_token; +} + +const requestApiInfo = (bearerToken, videoId, password) => { if (password) { videoId += `:${password}` } @@ -24,10 +63,8 @@ const requestApiInfo = (videoId, password) => { `https://api.vimeo.com/videos/${videoId}`, { headers: { - Accept: 'application/vnd.vimeo.*+json; version=3.4.2', - 'User-Agent': 'Vimeo/10.19.0 (com.vimeo; build:101900.57.0; iOS 17.5.1) Alamofire/5.9.0 VimeoNetworking/5.0.0', - Authorization: 'Basic MTMxNzViY2Y0NDE0YTQ5YzhjZTc0YmU0NjVjNDQxYzNkYWVjOWRlOTpHKzRvMmgzVUh4UkxjdU5FRW80cDNDbDhDWGR5dVJLNUJZZ055dHBHTTB4V1VzaG41bEx1a2hiN0NWYWNUcldSSW53dzRUdFRYZlJEZmFoTTArOTBUZkJHS3R4V2llYU04Qnl1bERSWWxUdXRidjNqR2J4SHFpVmtFSUcyRktuQw==', - 'Accept-Language': 'en' + ...genericHeaders, + Authorization: `Bearer ${bearerToken}`, } } ) @@ -151,9 +188,28 @@ export default async function(obj) { if (quality < 240) quality = 240; if (!quality || obj.isAudioOnly) quality = 9000; - const info = await requestApiInfo(obj.id, obj.password); + const bearerToken = await getBearer(); + if (!bearerToken) { + return { error: "fetch.fail" }; + } + + let info = await requestApiInfo(bearerToken, obj.id, obj.password); let response; + // auth error, try to refresh the token + if (info?.error_code === 8003) { + const newBearer = await getBearer(true); + if (!newBearer) { + return { error: "fetch.fail" }; + } + info = await requestApiInfo(newBearer, obj.id, obj.password); + } + + // if there's still no info, then return a generic error + if (!info || info.error_code) { + return { error: "fetch.empty" }; + } + if (obj.isAudioOnly) { response = await getHLS(info.config_url, { ...obj, quality }); } From 14b9a590d94b8710cfc149fac73e9e93cedc5837 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 4 Jul 2025 15:58:57 +0600 Subject: [PATCH 121/138] api/package: bump version to 11.2.2 --- api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/package.json b/api/package.json index 7d02a08b..162cd9fc 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "@imput/cobalt-api", "description": "save what you love", - "version": "11.2.1", + "version": "11.2.2", "author": "imput", "exports": "./src/cobalt.js", "type": "module", From 2ac0436f717d21c0719974e1c886c91d3c9597b9 Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 6 Jul 2025 00:04:40 +0600 Subject: [PATCH 122/138] api/tiktok: return empty error if there's nothing to download sometimes posts are broken and don't have any valid media --- api/src/processing/services/tiktok.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/src/processing/services/tiktok.js b/api/src/processing/services/tiktok.js index 3f5ea1fc..1e1605e0 100644 --- a/api/src/processing/services/tiktok.js +++ b/api/src/processing/services/tiktok.js @@ -168,4 +168,6 @@ export default async function(obj) { headers: { cookie } } } + + return { error: "fetch.empty" }; } From 43f4793448bdf09cbd83504f444866e3fbd9bfda Mon Sep 17 00:00:00 2001 From: wukko Date: Sun, 6 Jul 2025 17:37:51 +0600 Subject: [PATCH 123/138] web/i18n/ru: rephrase some strings --- web/i18n/ru/a11y/save.json | 4 ++-- web/i18n/ru/settings.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/i18n/ru/a11y/save.json b/web/i18n/ru/a11y/save.json index 5cf07427..83148125 100644 --- a/web/i18n/ru/a11y/save.json +++ b/web/i18n/ru/a11y/save.json @@ -1,12 +1,12 @@ { - "link_area": "область ввода ссылки", + "link_area": "поле ввода ссылки", "clear_input": "очистить поле ввода", "download": "скачать", "download.think": "обрабатываю ссылку...", "download.check": "проверяю загрузку...", "download.done": "загрузка завершена", "download.error": "ошибка загрузки", - "link_area.turnstile": "область ввода ссылки. проверяю, что ты не робот.", + "link_area.turnstile": "поле ввода ссылки. проверяю, что ты не робот.", "tutorial.shortcut.photos": "добавить команду \"в фото\"", "tutorial.shortcut.files": "добавить команду \"в файлы\"" } diff --git a/web/i18n/ru/settings.json b/web/i18n/ru/settings.json index 937c4b54..044087c2 100644 --- a/web/i18n/ru/settings.json +++ b/web/i18n/ru/settings.json @@ -45,7 +45,7 @@ "audio.youtube.dub.description": "cobalt будет использовать дублированную аудиодорожку для выбранного языка, если она доступна. в противном случае будет использоваться оригинальная.", "language.auto.description": "если доступен перевод, то кобальт будет использовать язык твоего браузера. в ином случае будет использоваться английский.", "theme.description": "авто тема переключается между светлой и тёмной темой в зависимости от системной темы.", - "page.debug": "инфа для гиков", + "page.debug": "инфа для зануд", "page.appearance": "внешний вид", "page.instances": "инстансы", "page.advanced": "продвинутые", From 2e86a6ca7030d108df7a2a77d7ca840b9efd4cb4 Mon Sep 17 00:00:00 2001 From: wukko Date: Mon, 7 Jul 2025 18:08:26 +0600 Subject: [PATCH 124/138] api/bilibili: don't return isHLS videos are no longer HLS i guess --- api/src/processing/services/bilibili.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/src/processing/services/bilibili.js b/api/src/processing/services/bilibili.js index 8747a781..a77ce10c 100644 --- a/api/src/processing/services/bilibili.js +++ b/api/src/processing/services/bilibili.js @@ -18,7 +18,7 @@ function extractBestQuality(dashData) { } async function com_download(id) { - let html = await fetch(`https://bilibili.com/video/${id}`, { + const html = await fetch(`https://bilibili.com/video/${id}`, { headers: { "user-agent": genericUserAgent } @@ -34,7 +34,10 @@ async function com_download(id) { return { error: "fetch.empty" }; } - let streamData = JSON.parse(html.split('')[0]); + const streamData = JSON.parse( + html.split('')[0] + ); + if (streamData.data.timelength > env.durationLimit * 1000) { return { error: "content.too_long" }; } @@ -48,7 +51,6 @@ async function com_download(id) { urls: [video.baseUrl, audio.baseUrl], audioFilename: `bilibili_${id}_audio`, filename: `bilibili_${id}_${video.width}x${video.height}.mp4`, - isHLS: true }; } From 40da8a46d609bf8f7e33ff0279ae825d7be0059b Mon Sep 17 00:00:00 2001 From: wukko Date: Mon, 7 Jul 2025 20:09:02 +0600 Subject: [PATCH 125/138] api/config: update chromium version in generic user agent --- api/src/config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/config.js b/api/src/config.js index 8f1579b8..2d539c0d 100644 --- a/api/src/config.js +++ b/api/src/config.js @@ -5,7 +5,7 @@ const version = await getVersion(); const env = loadEnvs(); -const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"; +const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"; const cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`; export const canonicalEnv = Object.freeze(structuredClone(process.env)); From 51a9680b39cc353bdcd0fa6a52f9eed920b58736 Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 8 Jul 2025 20:58:19 +0600 Subject: [PATCH 126/138] api/match: fix localDisabled accidentally left the old naming of the option here, typescript would've prevented this --- api/src/processing/match.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/processing/match.js b/api/src/processing/match.js index 360ed532..957c80d0 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -314,7 +314,7 @@ export default async function({ host, patternMatch, params, authType }) { let localProcessing = params.localProcessing; const lpEnv = env.forceLocalProcessing; const shouldForceLocal = lpEnv === "always" || (lpEnv === "session" && authType === "session"); - const localDisabled = (!localProcessing || localProcessing === "none"); + const localDisabled = (!localProcessing || localProcessing === "disabled"); if (shouldForceLocal && localDisabled) { localProcessing = "preferred"; From 94a8eab5e03d4fd660c35f5107773fad02ee1e96 Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 8 Jul 2025 21:20:13 +0600 Subject: [PATCH 127/138] web/i18n/save: rephrase the disclaimer to cover our ass more --- web/i18n/en/save.json | 2 +- web/i18n/ru/save.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/i18n/en/save.json b/web/i18n/en/save.json index 361361f8..afef0bbb 100644 --- a/web/i18n/en/save.json +++ b/web/i18n/en/save.json @@ -10,7 +10,7 @@ "services.title": "supported services", "services.title_show": "show supported services", "services.title_hide": "hide supported services", - "services.disclaimer": "cobalt is not affiliated with any of the services listed above.", + "services.disclaimer": "support of a service doesn't imply affiliation, endorsement, or any other form of support other than technical compatibility.", "tutorial.title": "how to save on ios?", "tutorial.intro": "to save media conveniently on ios, you'll need to use a companion siri shortcut from the share sheet.", diff --git a/web/i18n/ru/save.json b/web/i18n/ru/save.json index 60bb2998..26dedbd6 100644 --- a/web/i18n/ru/save.json +++ b/web/i18n/ru/save.json @@ -10,7 +10,7 @@ "services.title": "поддерживаемые сервисы", "services.title_show": "показать поддерживаемые сервисы", "services.title_hide": "скрыть поддерживаемые сервисы", - "services.disclaimer": "кобальт не аффилирован ни с одним из перечисленных выше сервисов.\n\nдеятельность meta platforms (владельца facebook и instagram) запрещена на территории РФ и признана экстремистской.", + "services.disclaimer": "поддержка сервиса не означает аффилированность, одобрение или любую другую форму поддержки, кроме технической совместимости.\n\nдеятельность владельца facebook и instagram запрещена на территории РФ и признана экстремистской.", "tutorial.step.1": "добавь команды-компаньоны:", "tutorial.step.2": "нажми кнопку \"поделиться\" в диалоге сохранения кобальта.", "tutorial.step.3": "выбери нужную команду в окне обмена.", From e8113a83de3c801934f924dc91adeeb4042fa1bf Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 8 Jul 2025 21:22:45 +0600 Subject: [PATCH 128/138] web/changelog/11.2: add info about vk download speed --- web/changelogs/11.2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/web/changelogs/11.2.md b/web/changelogs/11.2.md index 3cc6ed29..f1ee42b7 100644 --- a/web/changelogs/11.2.md +++ b/web/changelogs/11.2.md @@ -51,6 +51,7 @@ by the way, we also made it possible to [choose any preferred media container](/ - pinterest now returns an appropriate error when a pin is unavailable. - AI dubs on youtube are no longer accidentally selected as default tracks. - youtube HLS preference is now deprecated, but can be enabled on a self-hosted instance via `ENABLE_DEPRECATED_YOUTUBE_HLS` in env. +- downloads from vk are now way faster. ## web app improvements - improved compatibility of local processing & related code with older browsers. From 58ea4aed01383ead74d5e32e75335eddc2f015be Mon Sep 17 00:00:00 2001 From: wukko Date: Wed, 9 Jul 2025 15:56:22 +0600 Subject: [PATCH 129/138] api/soundcloud: check if a cover url returns 200 some songs don't have a cover but artwork_url is still defined, even though the response is always 404 --- api/src/processing/services/soundcloud.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/api/src/processing/services/soundcloud.js b/api/src/processing/services/soundcloud.js index 1c04a366..a73e6474 100644 --- a/api/src/processing/services/soundcloud.js +++ b/api/src/processing/services/soundcloud.js @@ -148,7 +148,14 @@ export default async function(obj) { let cover; if (json.artwork_url) { - cover = json.artwork_url.replace(/-large/, "-t1080x1080"); + const coverUrl = json.artwork_url.replace(/-large/, "-t1080x1080"); + const testCover = await fetch(coverUrl) + .then(r => r.status === 200) + .catch(() => {}); + + if (testCover) { + cover = coverUrl; + } } return { From 1a499238aaaa124abdd2c64c4b8e8fb46c7fd2f4 Mon Sep 17 00:00:00 2001 From: wukko Date: Wed, 9 Jul 2025 16:13:53 +0600 Subject: [PATCH 130/138] api/package: bump version to 11.2.3 --- api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/package.json b/api/package.json index 162cd9fc..0a3390f4 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "@imput/cobalt-api", "description": "save what you love", - "version": "11.2.2", + "version": "11.2.3", "author": "imput", "exports": "./src/cobalt.js", "type": "module", From 8353bd20752269bff17d264f3f7377d2906c720a Mon Sep 17 00:00:00 2001 From: wukko Date: Wed, 9 Jul 2025 16:14:03 +0600 Subject: [PATCH 131/138] web/package: bump version to 11.2.4 --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index ad470d87..b6d7d886 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@imput/cobalt-web", - "version": "11.2.3", + "version": "11.2.4", "type": "module", "private": true, "scripts": { From 172fb4c561b8939090b171f04793b00b33662806 Mon Sep 17 00:00:00 2001 From: wukko Date: Wed, 9 Jul 2025 16:23:12 +0600 Subject: [PATCH 132/138] web/i18n/save: improve clarity of the services disclaimer --- web/i18n/en/save.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/i18n/en/save.json b/web/i18n/en/save.json index afef0bbb..91716ab4 100644 --- a/web/i18n/en/save.json +++ b/web/i18n/en/save.json @@ -10,7 +10,7 @@ "services.title": "supported services", "services.title_show": "show supported services", "services.title_hide": "hide supported services", - "services.disclaimer": "support of a service doesn't imply affiliation, endorsement, or any other form of support other than technical compatibility.", + "services.disclaimer": "support for a service does not imply affiliation, endorsement, or any form of support other than technical compatibility.", "tutorial.title": "how to save on ios?", "tutorial.intro": "to save media conveniently on ios, you'll need to use a companion siri shortcut from the share sheet.", From 61de303dc4b4fa98eb61fbf50a57eec50c53bc52 Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 10 Jul 2025 00:49:33 +0600 Subject: [PATCH 133/138] api: add support for newgrounds closes #620, replaces #1368 Co-authored-by: hyperdefined --- api/README.md | 1 + api/src/processing/match-action.js | 1 + api/src/processing/match.js | 8 ++ api/src/processing/service-config.js | 6 ++ api/src/processing/service-patterns.js | 3 + api/src/processing/services/newgrounds.js | 103 ++++++++++++++++++++++ api/src/util/tests/newgrounds.json | 42 +++++++++ 7 files changed, 164 insertions(+) create mode 100644 api/src/processing/services/newgrounds.js create mode 100644 api/src/util/tests/newgrounds.json diff --git a/api/README.md b/api/README.md index 36c1dc89..3bdfb519 100644 --- a/api/README.md +++ b/api/README.md @@ -23,6 +23,7 @@ if the desired service isn't supported yet, feel free to create an appropriate i | instagram | ✅ | ✅ | ✅ | ➖ | ➖ | | facebook | ✅ | ❌ | ✅ | ➖ | ➖ | | loom | ✅ | ❌ | ✅ | ✅ | ➖ | +| newgrounds | ✅ | ✅ | ✅ | ✅ | ✅ | | ok.ru | ✅ | ❌ | ✅ | ✅ | ✅ | | pinterest | ✅ | ✅ | ✅ | ➖ | ➖ | | reddit | ✅ | ✅ | ✅ | ❌ | ❌ | diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index 6d1b0254..5852b19d 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -180,6 +180,7 @@ export default function({ case "ok": case "xiaohongshu": + case "newgrounds": params = { type: "proxy" }; break; diff --git a/api/src/processing/match.js b/api/src/processing/match.js index 957c80d0..1265297c 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -29,6 +29,7 @@ import loom from "./services/loom.js"; import facebook from "./services/facebook.js"; import bluesky from "./services/bluesky.js"; import xiaohongshu from "./services/xiaohongshu.js"; +import newgrounds from "./services/newgrounds.js"; let freebind; @@ -268,6 +269,13 @@ export default async function({ host, patternMatch, params, authType }) { }); break; + case "newgrounds": + r = await newgrounds({ + ...patternMatch, + quality: params.videoQuality, + }); + break; + default: return createResponse("error", { code: "error.api.service.unsupported" diff --git a/api/src/processing/service-config.js b/api/src/processing/service-config.js index 3ffcf10a..906c23da 100644 --- a/api/src/processing/service-config.js +++ b/api/src/processing/service-config.js @@ -74,6 +74,12 @@ export const services = { "url_shortener/:shortLink" ], }, + newgrounds: { + patterns: [ + "portal/view/:id", + "audio/listen/:audioId", + ] + }, reddit: { patterns: [ "comments/:id", diff --git a/api/src/processing/service-patterns.js b/api/src/processing/service-patterns.js index fd6daef9..e68a20be 100644 --- a/api/src/processing/service-patterns.js +++ b/api/src/processing/service-patterns.js @@ -79,4 +79,7 @@ export const testers = { "xiaohongshu": pattern => pattern.id?.length <= 24 && pattern.token?.length <= 64 || pattern.shareId?.length <= 24, + + "newgrounds": pattern => + pattern.id?.length <= 12 || pattern.audioId?.length <= 12, } diff --git a/api/src/processing/services/newgrounds.js b/api/src/processing/services/newgrounds.js new file mode 100644 index 00000000..7519a6cf --- /dev/null +++ b/api/src/processing/services/newgrounds.js @@ -0,0 +1,103 @@ +import { genericUserAgent } from "../../config.js"; + +const getVideo = async ({ id, quality }) => { + const json = await fetch(`https://www.newgrounds.com/portal/video/${id}`, { + headers: { + "User-Agent": genericUserAgent, + "X-Requested-With": "XMLHttpRequest", // required to get the JSON response + } + }) + .then(r => r.json()) + .catch(() => {}); + + if (!json) return { error: "fetch.empty" }; + + const videoSources = json.sources; + const videoQualities = Object.keys(videoSources); + + if (videoQualities.length === 0) { + return { error: "fetch.empty" }; + } + + const bestVideo = videoSources[videoQualities[0]]?.[0], + userQuality = quality === "2160" ? "4k" : `${quality}p`, + preferredVideo = videoSources[userQuality]?.[0], + video = preferredVideo || bestVideo, + videoQuality = preferredVideo ? userQuality : videoQualities[0]; + + if (!bestVideo || !video.type.includes("mp4")) { + return { error: "fetch.empty" }; + } + + const fileMetadata = { + title: decodeURIComponent(json.title), + artist: decodeURIComponent(json.author), + } + + return { + urls: video.src, + filenameAttributes: { + service: "newgrounds", + id, + title: fileMetadata.title, + author: fileMetadata.artist, + extension: "mp4", + qualityLabel: videoQuality, + resolution: videoQuality, + }, + fileMetadata, + } +} + +const getMusic = async ({ id }) => { + const html = await fetch(`https://www.newgrounds.com/audio/listen/${id}`, { + headers: { + "User-Agent": genericUserAgent, + } + }) + .then(r => r.text()) + .catch(() => {}); + + if (!html) return { error: "fetch.fail" }; + + const params = JSON.parse( + `{${html.split(',"params":{')[1]?.split(',"images":')[0]}}` + ); + if (!params) return { error: "fetch.empty" }; + + if (!params.name || !params.artist || !params.filename || !params.icon) { + return { error: "fetch.empty" }; + } + + const fileMetadata = { + title: decodeURIComponent(params.name), + artist: decodeURIComponent(params.artist), + } + + return { + urls: params.filename, + filenameAttributes: { + service: "newgrounds", + id, + title: fileMetadata.title, + author: fileMetadata.artist, + }, + fileMetadata, + cover: + params.icon.includes(".png?") || params.icon.includes(".jpg?") + ? params.icon + : undefined, + isAudioOnly: true, + bestAudio: "mp3", + } +} + +export default function({ id, audioId, quality }) { + if (id) { + return getVideo({ id, quality }); + } else if (audioId) { + return getMusic({ id: audioId }); + } + + return { error: "fetch.empty" }; +} diff --git a/api/src/util/tests/newgrounds.json b/api/src/util/tests/newgrounds.json new file mode 100644 index 00000000..e0c9c83d --- /dev/null +++ b/api/src/util/tests/newgrounds.json @@ -0,0 +1,42 @@ +[ + { + "name": "regular video", + "url": "https://www.newgrounds.com/portal/view/938050", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "regular video (audio only)", + "url": "https://www.newgrounds.com/portal/view/938050", + "params": { + "downloadMode": "audio" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "regular video (muted)", + "url": "https://www.newgrounds.com/portal/view/938050", + "params": { + "downloadMode": "mute" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "regular music", + "url": "https://www.newgrounds.com/audio/listen/500476", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + } +] From 02386544f324fc2982ab196541a3743c080888d1 Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 10 Jul 2025 18:19:18 +0600 Subject: [PATCH 134/138] api/stream/shared: add tiktok headers referer is now required to access video links --- api/src/stream/shared.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/src/stream/shared.js b/api/src/stream/shared.js index ec06339d..6d268564 100644 --- a/api/src/stream/shared.js +++ b/api/src/stream/shared.js @@ -19,6 +19,9 @@ const serviceHeaders = { }, vk: { 'user-agent': vkClientAgent + }, + tiktok: { + referer: 'https://www.tiktok.com/', } } From 2425f189086030ca260e6daac3be8270927bc5d3 Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 10 Jul 2025 19:06:52 +0600 Subject: [PATCH 135/138] api/package: bump version to 11.3 --- api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/package.json b/api/package.json index 0a3390f4..63b1803d 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "@imput/cobalt-api", "description": "save what you love", - "version": "11.2.3", + "version": "11.3", "author": "imput", "exports": "./src/cobalt.js", "type": "module", From e4b53880af4026b8be6a870ae7d8133f744a6181 Mon Sep 17 00:00:00 2001 From: wukko Date: Thu, 10 Jul 2025 19:07:03 +0600 Subject: [PATCH 136/138] web/package: bump version to 11.3 --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index b6d7d886..7de6f75d 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@imput/cobalt-web", - "version": "11.2.4", + "version": "11.3", "type": "module", "private": true, "scripts": { From 60f02b18e42da583a972d0ab1e9bdba17eedc3bc Mon Sep 17 00:00:00 2001 From: jj Date: Fri, 11 Jul 2025 18:34:42 +0000 Subject: [PATCH 137/138] vimeo: use android client for session --- api/src/processing/services/vimeo.js | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/api/src/processing/services/vimeo.js b/api/src/processing/services/vimeo.js index 7be3a4b6..8c51c026 100644 --- a/api/src/processing/services/vimeo.js +++ b/api/src/processing/services/vimeo.js @@ -17,9 +17,9 @@ const resolutionMatch = { const genericHeaders = { Accept: 'application/vnd.vimeo.*+json; version=3.4.10', - 'User-Agent': 'Vimeo/11.13.0 (com.vimeo; build:250619.102023.0; iOS 18.5.0) Alamofire/5.9.0 VimeoNetworking/5.0.0', - Authorization: 'Basic MTMxNzViY2Y0NDE0YTQ5YzhjZTc0YmU0NjVjNDQxYzNkYWVjOWRlOTpHKzRvMmgzVUh4UkxjdU5FRW80cDNDbDhDWGR5dVJLNUJZZ055dHBHTTB4V1VzaG41bEx1a2hiN0NWYWNUcldSSW53dzRUdFRYZlJEZmFoTTArOTBUZkJHS3R4V2llYU04Qnl1bERSWWxUdXRidjNqR2J4SHFpVmtFSUcyRktuQw==', - 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': 'com.vimeo.android.videoapp (Google, Pixel 7a, google, Android 16/36 Version 11.8.1) Kotlin VimeoNetworking/3.12.0', + Authorization: 'Basic NzRmYTg5YjgxMWExY2JiNzUwZDg1MjhkMTYzZjQ4YWYyOGEyZGJlMTp4OGx2NFd3QnNvY1lkamI2UVZsdjdDYlNwSDUrdm50YzdNNThvWDcwN1JrenJGZC9tR1lReUNlRjRSVklZeWhYZVpRS0tBcU9YYzRoTGY2Z1dlVkJFYkdJc0dMRHpoZWFZbU0reDRqZ1dkZ1diZmdIdGUrNUM5RVBySlM0VG1qcw==', + 'Accept-Language': 'en', } let bearer = ''; @@ -28,19 +28,16 @@ const getBearer = async (refresh = false) => { if (bearer && !refresh) return bearer; const oauthResponse = await fetch( - `https://api.vimeo.com/oauth/authorize/client?sizes=216,288,300,360,640,960,1280,1920&cdm_type=fairplay`, + 'https://api.vimeo.com/oauth/authorize/client', { method: 'POST', - body: JSON.stringify({ - scope: 'public private purchased create edit delete interact upload stats', + body: new URLSearchParams({ + scope: 'private public create edit delete interact upload purchased stats', grant_type: 'client_credentials', - // device_identifier is a long ass base64 string of seemingly - // random data, but it doesn't seem to be required, so we just omit it lol - device_identifier: '', - }), + }).toString(), headers: { ...genericHeaders, - 'Content-Type': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', } } ) From 63ee694d366e6ca7e60fd7d87cb983b00f04671c Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 12 Jul 2025 23:07:18 +0600 Subject: [PATCH 138/138] api/package: bump version to 11.3.1 --- api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/package.json b/api/package.json index 63b1803d..c03064a5 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "@imput/cobalt-api", "description": "save what you love", - "version": "11.3", + "version": "11.3.1", "author": "imput", "exports": "./src/cobalt.js", "type": "module",