From dbb83b9e972563bdd6885a48088847e58d49239b Mon Sep 17 00:00:00 2001 From: wukko Date: Wed, 11 Jun 2025 17:50:28 +0600 Subject: [PATCH 01/89] 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 02/89] 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 03/89] 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 04/89] 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 05/89] 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 06/89] 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 07/89] 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 08/89] 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 33/89] 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 34/89] 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 35/89] 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 36/89] 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 37/89] 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 38/89] 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 39/89] 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 40/89] 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 41/89] 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 42/89] 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 43/89] 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 44/89] 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 45/89] 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 46/89] 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 47/89] 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 48/89] 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 49/89] 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 50/89] 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 51/89] 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 52/89] 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 53/89] 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 54/89] 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 55/89] 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 56/89] 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 57/89] 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 58/89] 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 59/89] 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 60/89] 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 61/89] 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 62/89] 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 63/89] 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 64/89] 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 65/89] 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 66/89] 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 67/89] 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 68/89] 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 69/89] 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 70/89] 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 71/89] 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 72/89] 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 73/89] 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 74/89] 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 75/89] 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 76/89] 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 77/89] 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 78/89] 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 79/89] 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 80/89] 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 81/89] 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 82/89] 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 83/89] 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 84/89] 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 85/89] 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 86/89] 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 87/89] 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 88/89] 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 89/89] 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"