diff --git a/api/package.json b/api/package.json index de1e170f..fed69e73 100644 --- a/api/package.json +++ b/api/package.json @@ -39,7 +39,7 @@ "set-cookie-parser": "2.6.0", "undici": "^5.19.1", "url-pattern": "1.0.3", - "youtubei.js": "^13.4.0", + "youtubei.js": "^14.0.0", "zod": "^3.23.8" }, "optionalDependencies": { diff --git a/api/src/core/api.js b/api/src/core/api.js index eb5cf4ff..0487ad7a 100644 --- a/api/src/core/api.js +++ b/api/src/core/api.js @@ -156,6 +156,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { return fail(res, `error.api.auth.key.${error}`); } + req.isApiKey = true; return next(); }); @@ -244,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"); @@ -264,6 +268,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { patternMatch: parsed.patternMatch, params: normalizedRequest, isSession: req.isSession ?? false, + isApiKey: req.isApiKey ?? false, }); res.status(result.status).json(result.body); diff --git a/api/src/core/env.js b/api/src/core/env.js index 37ab36c1..f7600f65 100644 --- a/api/src/core/env.js +++ b/api/src/core/env.js @@ -8,8 +8,10 @@ import * as cluster from "../misc/cluster.js"; import { Green, Yellow } from "../misc/console-text.js"; 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)) { @@ -37,7 +39,7 @@ export const loadEnvs = (env = process.env) => { tunnelRateLimitMax: (env.TUNNEL_RATELIMIT_MAX && parseInt(env.TUNNEL_RATELIMIT_MAX)) || 40, sessionRateLimitWindow: (env.SESSION_RATELIMIT_WINDOW && parseInt(env.SESSION_RATELIMIT_WINDOW)) || 60, - sessionRateLimit: (env.SESSION_RATELIMIT && parseInt(env.SESSION_RATELIMIT)) || 10, + sessionRateLimit: (env.SESSION_RATELIMIT_MAX && parseInt(env.SESSION_RATELIMIT_MAX)) || 10, durationLimit: (env.DURATION_LIMIT && parseInt(env.DURATION_LIMIT)) || 10800, streamLifespan: (env.TUNNEL_LIFESPAN && parseInt(env.TUNNEL_LIFESPAN)) || 90, @@ -63,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, @@ -74,6 +77,9 @@ export const loadEnvs = (env = process.env) => { // "never" | "session" | "always" forceLocalProcessing: env.FORCE_LOCAL_PROCESSING ?? "never", + // "never" | "key" | "always" + enableDeprecatedYoutubeHls: env.ENABLE_DEPRECATED_YOUTUBE_HLS ?? "never", + envFile: env.API_ENV_FILE, envRemoteReloadInterval: 300, }; @@ -106,6 +112,12 @@ export const validateEnvs = async (env) => { throw new Error("Invalid FORCE_LOCAL_PROCESSING"); } + if (env.enableDeprecatedYoutubeHls && !youtubeHlsOptions.includes(env.enableDeprecatedYoutubeHls)) { + console.error("ENABLE_DEPRECATED_YOUTUBE_HLS is invalid."); + console.error(`Supported options are are: ${youtubeHlsOptions.join(', ')}\n`); + throw new Error("Invalid ENABLE_DEPRECATED_YOUTUBE_HLS"); + } + if (env.externalProxy && env.freebindCIDR) { throw new Error('freebind is not available when external proxy is enabled') } 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/processing/match-action.js b/api/src/processing/match-action.js index e8933784..ba340dc9 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -4,6 +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 { convertLanguageCode } from "../misc/language-codes.js"; const extraProcessingTypes = ["merge", "remux", "mute", "audio", "gif"]; @@ -19,7 +20,7 @@ export default function({ requestIP, audioBitrate, alwaysProxy, - localProcessing + localProcessing, }) { let action, responseType = "tunnel", @@ -31,7 +32,10 @@ export default function({ createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename, fileMetadata: !disableMetadata ? r.fileMetadata : false, requestIP, - originalRequest: r.originalRequest + originalRequest: r.originalRequest, + subtitles: r.subtitles, + cover: !disableMetadata ? r.cover : false, + cropCover: !disableMetadata ? r.cropCover : false, }, params = {}; @@ -143,7 +147,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"; } @@ -157,9 +163,22 @@ export default function({ } break; - case "ok": + case "loom": + if (r.subtitles) { + params = { type: "remux" }; + } else { + responseType = "redirect"; + } + break; + case "vk": case "tiktok": + params = { + type: r.subtitles ? "remux" : "proxy" + }; + break; + + case "ok": case "xiaohongshu": params = { type: "proxy" }; break; @@ -170,7 +189,6 @@ export default function({ case "pinterest": case "streamable": case "snapchat": - case "loom": case "twitch": responseType = "redirect"; break; @@ -178,7 +196,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" }) @@ -226,6 +244,7 @@ export default function({ defaultParams.filename += `.${audioFormat}`; } + // alwaysProxy is set to true in match.js if localProcessing is forced if (alwaysProxy && responseType === "redirect") { responseType = "tunnel"; params.type = "proxy"; @@ -233,8 +252,26 @@ export default function({ // TODO: add support for HLS // (very painful) - if (localProcessing && !params.isHLS && extraProcessingTypes.includes(params.type)) { - responseType = "local-processing"; + if (!params.isHLS && responseType !== "picker") { + const isPreferredWithExtra = + localProcessing === "preferred" && extraProcessingTypes.includes(params.type); + + if (localProcessing === "forced" || isPreferredWithExtra) { + responseType = "local-processing"; + } + } + + // extractors usually return ISO 639-1 language codes, + // but video players expect ISO 639-2, so we convert them here + if (defaultParams.fileMetadata?.sublanguage?.length === 2) { + const code = convertLanguageCode(defaultParams.fileMetadata.sublanguage); + if (code) { + defaultParams.fileMetadata.sublanguage = code; + } else { + // if a language code couldn't be converted, + // then we don't want it at all + delete defaultParams.fileMetadata.sublanguage; + } } return createResponse( diff --git a/api/src/processing/match.js b/api/src/processing/match.js index 65a021b0..1e493be6 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -32,7 +32,7 @@ import xiaohongshu from "./services/xiaohongshu.js"; let freebind; -export default async function({ host, patternMatch, params, isSession }) { +export default async function({ host, patternMatch, params, isSession, isApiKey }) { const { url } = params; assert(url instanceof URL); let dispatcher, requestIP; @@ -65,6 +65,17 @@ export default async function({ host, patternMatch, params, isSession }) { }); } + // youtubeHLS will be fully removed in the future + let youtubeHLS = params.youtubeHLS; + const hlsEnv = env.enableDeprecatedYoutubeHls; + + if (hlsEnv === "never" || (hlsEnv === "key" && !isApiKey)) { + youtubeHLS = false; + } + + const subtitleLang = + params.subtitleLang !== "none" ? params.subtitleLang : undefined; + switch (host) { case "twitter": r = await twitter({ @@ -81,7 +92,8 @@ export default async function({ host, patternMatch, params, isSession }) { ownerId: patternMatch.ownerId, videoId: patternMatch.videoId, accessKey: patternMatch.accessKey, - quality: params.videoQuality + quality: params.videoQuality, + subtitleLang, }); break; @@ -101,16 +113,18 @@ export default async function({ host, patternMatch, params, isSession }) { dispatcher, id: patternMatch.id.slice(0, 11), quality: params.videoQuality, - format: params.youtubeVideoCodec, + codec: params.youtubeVideoCodec, + container: params.youtubeVideoContainer, isAudioOnly, isAudioMuted, dubLang: params.youtubeDubLang, - youtubeHLS: params.youtubeHLS, + youtubeHLS, + subtitleLang, } if (url.hostname === "music.youtube.com" || isAudioOnly) { fetchInfo.quality = "1080"; - fetchInfo.format = "vp9"; + fetchInfo.codec = "vp9"; fetchInfo.isAudioOnly = true; fetchInfo.isAudioMuted = false; @@ -137,6 +151,7 @@ export default async function({ host, patternMatch, params, isSession }) { isAudioOnly, h265: params.allowH265, alwaysProxy: params.alwaysProxy, + subtitleLang, }); break; @@ -154,6 +169,7 @@ export default async function({ host, patternMatch, params, isSession }) { password: patternMatch.password, quality: params.videoQuality, isAudioOnly, + subtitleLang, }); break; @@ -205,6 +221,7 @@ export default async function({ host, patternMatch, params, isSession }) { key: patternMatch.key, quality: params.videoQuality, isAudioOnly, + subtitleLang, }); break; @@ -221,7 +238,8 @@ export default async function({ host, patternMatch, params, isSession }) { case "loom": r = await loom({ - id: patternMatch.id + id: patternMatch.id, + subtitleLang, }); break; @@ -293,11 +311,12 @@ export default async function({ host, patternMatch, params, isSession }) { } 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({ @@ -311,7 +330,7 @@ export default async function({ host, patternMatch, params, isSession }) { convertGif: params.convertGif, requestIP, audioBitrate: params.audioBitrate, - alwaysProxy: params.alwaysProxy, + alwaysProxy: params.alwaysProxy || localProcessing === "forced", localProcessing, }) } catch { diff --git a/api/src/processing/request.js b/api/src/processing/request.js index 697e67fc..b9ebca05 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 } @@ -60,12 +60,15 @@ export function createResponse(responseType, responseData) { type: mime.getType(responseData?.filename) || undefined, filename: responseData?.filename, metadata: responseData?.fileMetadata || undefined, + subtitles: !!responseData?.subtitles || undefined, }, audio: { copy: responseData?.audioCopy, format: responseData?.audioFormat, bitrate: responseData?.audioBitrate, + cover: !!responseData?.cover || undefined, + cropCover: !!responseData?.cropCover || undefined, }, isHLS: responseData?.isHLS, @@ -108,11 +111,16 @@ export function createResponse(responseType, responseData) { } } } catch { - return internalError() + return internalError(); } } 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 } )); diff --git a/api/src/processing/schema.js b/api/src/processing/schema.js index be895efd..7570a8b0 100644 --- a/api/src/processing/schema.js +++ b/api/src/processing/schema.js @@ -26,16 +26,30 @@ export const apiSchema = z.object({ ["h264", "av1", "vp9"] ).default("h264"), + youtubeVideoContainer: z.enum( + ["auto", "mp4", "webm", "mkv"] + ).default("auto"), + videoQuality: z.enum( ["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) .regex(/^[0-9a-zA-Z\-]+$/) .optional(), + subtitleLang: z.string() + .min(2) + .max(8) + .regex(/^[0-9a-zA-Z\-]+$/) + .optional(), + disableMetadata: z.boolean().default(false), allowH265: z.boolean().default(false), @@ -43,13 +57,8 @@ 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 - twitterGif: z.boolean().default(false), - tiktokH265: z.boolean().default(false), }) .strict(); diff --git a/api/src/processing/service-config.js b/api/src/processing/service-config.js index 1c77c7bb..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: { @@ -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/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` } 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", 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/tiktok.js b/api/src/processing/services/tiktok.js index 93e07c50..3f5ea1fc 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/"; @@ -23,8 +24,10 @@ export default async function(obj) { if (html.startsWith(' s.LanguageCodeName.startsWith(langCode) && s.Format === "webvtt" + ) + if (subtitle) { + subtitles = subtitle.Url; + fileMetadata = { + sublanguage: langCode, + } + } + } return { urls: video, + subtitles, + fileMetadata, filename: videoFilename, headers: { cookie } } 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, diff --git a/api/src/processing/services/vk.js b/api/src/processing/services/vk.js index 33224d69..c07d964a 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.title.endsWith(".vtt") && s.lang.startsWith(subtitleLang) + ); + if (subtitle) { + subtitles = subtitle.url; + fileMetadata.sublanguage = subtitleLang; + } + } + return { urls: url, + subtitles, fileMetadata, filenameAttributes: { service: "vk", diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js index 74959063..55caa835 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -87,6 +87,83 @@ const cloneInnertube = async (customFetch, useSession) => { return yt; } +const getHlsVariants = async (hlsManifest, dispatcher) => { + if (!hlsManifest) { + return { error: "youtube.no_hls_streams" }; + } + + const fetchedHlsManifest = + await fetch(hlsManifest, { dispatcher }) + .then(r => r.status === 200 ? r.text() : undefined) + .catch(() => {}); + + if (!fetchedHlsManifest) { + return { error: "youtube.no_hls_streams" }; + } + + const variants = HLS.parse(fetchedHlsManifest).variants.sort( + (a, b) => Number(b.bandwidth) - Number(a.bandwidth) + ); + + if (!variants || variants.length === 0) { + return { error: "youtube.no_hls_streams" }; + } + + return variants; +} + +const getSubtitles = async (info, dispatcher, subtitleLang) => { + const preferredCap = info.captions.caption_tracks.find(caption => + caption.kind !== 'asr' && caption.language_code.startsWith(subtitleLang) + ); + + const captionsUrl = preferredCap?.base_url; + if (!captionsUrl) return; + + if (!captionsUrl.includes("exp=xpe")) { + let url = new URL(captionsUrl); + url.searchParams.set('fmt', 'vtt'); + + return { + url: url.toString(), + language: preferredCap.language_code, + } + } + + // if we have exp=xpe in the url, then captions are + // locked down and can't be accessed without a yummy potoken, + // so instead we just use subtitles from HLS + + const hlsVariants = await getHlsVariants( + info.streaming_data.hls_manifest_url, + dispatcher + ); + if (hlsVariants?.error) return; + + // all variants usually have the same set of subtitles + const hlsSubtitles = hlsVariants[0]?.subtitles; + if (!hlsSubtitles?.length) return; + + const preferredHls = hlsSubtitles.find( + subtitle => subtitle.language.startsWith(subtitleLang) + ); + + if (!preferredHls) return; + + const fetchedHlsSubs = + await fetch(preferredHls.uri, { dispatcher }) + .then(r => r.status === 200 ? r.text() : undefined) + .catch(() => {}); + + const parsedSubs = HLS.parse(fetchedHlsSubs); + if (!parsedSubs) return; + + return { + url: parsedSubs.segments[0]?.uri, + language: preferredHls.language, + } +} + export default async function (o) { const quality = o.quality === "max" ? 9000 : Number(o.quality); @@ -94,7 +171,7 @@ export default async function (o) { let innertubeClient = o.innertubeClient || env.customInnertubeClient || "IOS"; // HLS playlists from the iOS client don't contain the av1 video format. - if (useHLS && o.format === "av1") { + if (useHLS && o.codec === "av1") { useHLS = false; } @@ -104,18 +181,24 @@ export default async function (o) { // 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 && innertubeClient === "IOS" && ( - (quality > 1080 && o.format !== "h264") - || (quality > 1080 && o.format !== "vp9") + (quality > 1080 && o.codec !== "h264") + || (quality > 1080 && o.codec !== "vp9") ) ) ); + // we can get subtitles reliably only from the iOS client + if (o.subtitleLang) { + innertubeClient = "IOS"; + useSession = false; + } + if (useSession) { innertubeClient = env.ytSessionInnertubeClient || "WEB_EMBEDDED"; } @@ -222,37 +305,16 @@ export default async function (o) { return videoQualities.find(qual => qual >= shortestSide); } - let video, audio, dubbedLanguage, - codec = o.format || "h264", itag = o.itag; + let video, audio, subtitles, dubbedLanguage, + codec = o.codec || "h264", itag = o.itag; if (useHLS) { - const hlsManifest = info.streaming_data.hls_manifest_url; - - if (!hlsManifest) { - return { error: "youtube.no_hls_streams" }; - } - - const fetchedHlsManifest = await fetch(hlsManifest, { - dispatcher: o.dispatcher, - }).then(r => { - if (r.status === 200) { - return r.text(); - } else { - throw new Error("couldn't fetch the HLS playlist"); - } - }).catch(() => { }); - - if (!fetchedHlsManifest) { - return { error: "youtube.no_hls_streams" }; - } - - const variants = HLS.parse(fetchedHlsManifest).variants.sort( - (a, b) => Number(b.bandwidth) - Number(a.bandwidth) + const variants = await getHlsVariants( + info.streaming_data.hls_manifest_url, + o.dispatcher ); - if (!variants || variants.length === 0) { - return { error: "youtube.no_hls_streams" }; - } + if (variants?.error) return variants; const matchHlsCodec = codecs => ( codecs.includes(hlsCodecList[codec].videoCodec) @@ -403,6 +465,13 @@ export default async function (o) { if (!video) video = sorted_formats[codec].bestVideo; } + + if (o.subtitleLang && !o.isAudioOnly && info.captions?.caption_tracks?.length) { + const videoSubtitles = await getSubtitles(info, o.dispatcher, o.subtitleLang); + if (videoSubtitles) { + subtitles = videoSubtitles; + } + } } if (video?.drm_families || audio?.drm_families) { @@ -426,6 +495,10 @@ export default async function (o) { } } + if (subtitles) { + fileMetadata.sublanguage = subtitles.language; + } + const filenameAttributes = { service: "youtube", id: o.id, @@ -459,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, @@ -467,7 +549,10 @@ export default async function (o) { fileMetadata, bestAudio, isHLS: useHLS, - originalRequest + originalRequest, + + cover, + cropCover: basicInfo.author.endsWith("- Topic"), } } @@ -477,7 +562,7 @@ export default async function (o) { if (useHLS) { resolution = normalizeQuality(video.resolution); filenameAttributes.resolution = `${video.resolution.width}x${video.resolution.height}`; - filenameAttributes.extension = hlsCodecList[codec].container; + filenameAttributes.extension = o.container === "auto" ? hlsCodecList[codec].container : o.container; video = video.uri; audio = audio.uri; @@ -488,7 +573,7 @@ export default async function (o) { }); filenameAttributes.resolution = `${video.width}x${video.height}`; - filenameAttributes.extension = codecList[codec].container; + filenameAttributes.extension = o.container === "auto" ? codecList[codec].container : o.container; if (!clientsWithNoCipher.includes(innertubeClient) && innertube) { video = video.decipher(innertube.session.player); @@ -508,6 +593,7 @@ export default async function (o) { video, audio, ], + subtitles: subtitles?.url, filenameAttributes, fileMetadata, isHLS: useHLS, diff --git a/api/src/processing/url.js b/api/src/processing/url.js index 86c333f6..dbbda1cd 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/.*/ @@ -196,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); } @@ -207,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..f2e9e6dc 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) { + 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"; + } + } 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; +} diff --git a/api/src/stream/ffmpeg.js b/api/src/stream/ffmpeg.js new file mode 100644 index 00000000..4a72a309 --- /dev/null +++ b/api/src/stream/ffmpeg.js @@ -0,0 +1,215 @@ +import ffmpeg from "ffmpeg-static"; +import { spawn } from "child_process"; +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 { closeResponse, pipe, estimateTunnelLength, estimateAudioMultiplier } from "./shared.js"; + +const metadataTags = new Set([ + "album", + "composer", + "genre", + "copyright", + "title", + "artist", + "album_artist", + "track", + "date", + "sublanguage" +]); + +const convertMetadataToFFmpeg = (metadata) => { + const args = []; + + for (const [ name, value ] of Object.entries(metadata)) { + if (metadataTags.has(name)) { + if (name === "sublanguage") { + args.push('-metadata:s:s:0', `language=${value}`); + continue; + } + args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, '')}`); // skipcq: JS-0004 + } else { + throw `${name} metadata tag is not supported.`; + } + } + + return args; +} + +const killProcess = (p) => { + p?.kill('SIGTERM'); // ask the process to terminate itself gracefully + + setTimeout(() => { + if (p?.exitCode === null) + p?.kill('SIGKILL'); // brutally murder the process if it didn't quit + }, 5000); +} + +const getCommand = (args) => { + if (typeof env.processingPriority === 'number' && !isNaN(env.processingPriority)) { + return ['nice', ['-n', env.processingPriority.toString(), ffmpeg, ...args]] + } + return [ffmpeg, args] +} + +const render = async (res, streamInfo, ffargs, estimateMultiplier) => { + let process; + const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls]; + const shutdown = () => ( + killProcess(process), + closeResponse(res), + urls.map(destroyInternalStream) + ); + + try { + const args = [ + '-loglevel', '-8', + ...ffargs, + ]; + + 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, estimateMultiplier) + ); + + pipe(muxOutput, res, shutdown); + + process.on('close', shutdown); + res.on('finish', shutdown); + } catch { + shutdown(); + } +} + +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]); + + // 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, + '-map', `${urls.length}:s`, + '-c:s', format === 'mp4' ? 'mov_text' : 'webvtt', + ); + } + + if (urls.length === 2) { + args.push( + '-map', '0:v', + '-map', '1:a', + ); + } else { + args.push( + '-map', '0:v:0', + '-map', '0:a:0' + ); + } + + args.push( + '-c:v', 'copy', + ...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy']) + ); + + if (format === 'mp4') { + args.push('-movflags', 'faststart+frag_keyframe+empty_moov'); + } + + if (streamInfo.type !== 'mute' && streamInfo.isHLS && hlsExceptions.has(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) => { + 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', + ); + + await render( + res, + streamInfo, + args, + estimateAudioMultiplier(streamInfo) * 1.1, + ); +} + +const convertGif = async (streamInfo, res) => { + const args = [ + '-i', streamInfo.urls, + + '-vf', + 'scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse', + '-loop', '0', + + '-f', 'gif', 'pipe:3', + ]; + + await render( + res, + streamInfo, + args, + 60, + ); +} + +export default { + remux, + convertAudio, + convertGif, +} 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); diff --git a/api/src/stream/manage.js b/api/src/stream/manage.js index 6adcd553..93e9e652 100644 --- a/api/src/stream/manage.js +++ b/api/src/stream/manage.js @@ -41,7 +41,10 @@ export function createStream(obj) { audioFormat: obj.audioFormat, isHLS: obj.isHLS || false, - originalRequest: obj.originalRequest + originalRequest: obj.originalRequest, + + // url to a subtitle file + subtitles: obj.subtitles, }; // FIXME: this is now a Promise, but it is not awaited @@ -79,17 +82,39 @@ 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 + if (info.subtitles) { + proxyTunnels.push( + createStream({ + ...tunnelTemplate, + url: info.subtitles, + service: `${info?.service}-subtitles`, + }) + ); + } + + if (info.cover) { + proxyTunnels.push( + createStream({ + ...tunnelTemplate, + url: info.cover, + service: `${info?.service}-cover`, }) ); } @@ -111,7 +136,7 @@ export function getInternalTunnelFromURL(url) { return getInternalTunnel(id); } -export function createInternalStream(url, obj = {}) { +export function createInternalStream(url, obj = {}, isSubtitles) { assert(typeof url === 'string'); let dispatcher = obj.dispatcher; @@ -132,9 +157,12 @@ export function createInternalStream(url, obj = {}) { headers = new Map(Object.entries(obj.headers)); } + // subtitles don't need special treatment unlike big media files + const service = isSubtitles ? `${obj.service}-subtitles` : obj.service; + internalStreamCache.set(streamID, { url, - service: obj.service, + service, headers, controller, dispatcher, @@ -245,6 +273,14 @@ function wrapStream(streamInfo) { } } else throw 'invalid urls'; + if (streamInfo.subtitles) { + streamInfo.subtitles = createInternalStream( + streamInfo.subtitles, + streamInfo, + /*isSubtitles=*/true + ); + } + return streamInfo; } 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 e714f38e..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,23 +8,21 @@ 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); case "merge": - return await stream.merge(streamInfo, res); - 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); diff --git a/api/src/stream/types.js b/api/src/stream/types.js deleted file mode 100644 index 6b493efa..00000000 --- a/api/src/stream/types.js +++ /dev/null @@ -1,353 +0,0 @@ -import { Agent, request } from "undici"; -import ffmpeg from "ffmpeg-static"; -import { spawn } from "child_process"; -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"; - -const ffmpegArgs = { - webm: ["-c:v", "copy", "-c:a", "copy"], - mp4: ["-c:v", "copy", "-c:a", "copy", "-movflags", "faststart+frag_keyframe+empty_moov"], - m4a: ["-movflags", "frag_keyframe+empty_moov"], - gif: ["-vf", "scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", "-loop", "0"] -} - -const metadataTags = [ - "album", - "composer", - "genre", - "copyright", - "title", - "artist", - "album_artist", - "track", - "date", -]; - -const convertMetadataToFFmpeg = (metadata) => { - let args = []; - - for (const [ name, value ] of Object.entries(metadata)) { - if (metadataTags.includes(name)) { - args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`); // skipcq: JS-0004 - } else { - throw `${name} metadata tag is not supported.`; - } - } - - 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 - - setTimeout(() => { - if (p?.exitCode === null) - p?.kill('SIGKILL'); // brutally murder the process if it didn't quit - }, 5000); -} - -const getCommand = (args) => { - if (typeof env.processingPriority === 'number' && !isNaN(env.processingPriority)) { - return ['nice', ['-n', env.processingPriority.toString(), ffmpeg, ...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 merge = async (streamInfo, res) => { - let process; - const shutdown = () => ( - killProcess(process), - closeResponse(res), - streamInfo.urls.map(destroyInternalStream) - ); - - const headers = getHeaders(streamInfo.service); - const rawHeaders = toRawHeaders(headers); - - try { - if (streamInfo.urls.length !== 2) return shutdown(); - - const format = streamInfo.filename.split('.').pop(); - - let args = [ - '-loglevel', '-8', - '-headers', rawHeaders, - '-i', streamInfo.urls[0], - '-headers', rawHeaders, - '-i', streamInfo.urls[1], - '-map', '0:v', - '-map', '1:a', - ] - - args = args.concat(ffmpegArgs[format]); - - 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, '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(); - } -} - -const remux = async (streamInfo, res) => { - let process; - const shutdown = () => ( - killProcess(process), - closeResponse(res), - destroyInternalStream(streamInfo.urls) - ); - - try { - let args = [ - '-loglevel', '-8', - '-headers', toRawHeaders(getHeaders(streamInfo.service)), - ] - - if (streamInfo.service === "twitter") { - args.push('-seekable', '0') - } - - args.push( - '-i', streamInfo.urls, - '-c:v', 'copy', - ) - - if (streamInfo.type === "mute") { - args.push('-an'); - } - - if (hlsExceptions.includes(streamInfo.service)) { - if (streamInfo.type !== "mute") { - args.push('-c:a', 'aac') - } - args.push('-bsf:a', 'aac_adtstoasc'); - } - - let format = streamInfo.filename.split('.').pop(); - if (format === "mp4") { - args.push('-movflags', 'faststart+frag_keyframe+empty_moov') - } - - 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(); - } -} - -const convertAudio = async (streamInfo, res) => { - let process; - const shutdown = () => ( - killProcess(process), - closeResponse(res), - destroyInternalStream(streamInfo.urls) - ); - - 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 (ffmpegArgs[streamInfo.audioFormat]) { - args = args.concat(ffmpegArgs[streamInfo.audioFormat]) - } - - 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(); - } -} - -const convertGif = async (streamInfo, res) => { - let process; - const shutdown = () => (killProcess(process), closeResponse(res)); - - try { - let args = [ - '-loglevel', '-8' - ] - - if (streamInfo.service === "twitter") { - args.push('-seekable', '0') - } - - args.push('-i', streamInfo.urls); - args = args.concat(ffmpegArgs.gif); - 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(); - } -} - -export default { - proxy, - merge, - remux, - convertAudio, - convertGif, -} 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, 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" diff --git a/docs/api-env-variables.md b/docs/api-env-variables.md index 92e7a6cf..5a836bfa 100644 --- a/docs/api-env-variables.md +++ b/docs/api-env-variables.md @@ -34,9 +34,9 @@ this document is not final and will expand over time. feel free to improve it! | RATELIMIT_WINDOW | `60` | `120` | | RATELIMIT_MAX | `20` | `30` | | SESSION_RATELIMIT_WINDOW | `60` | `60` | -| SESSION_RATELIMIT | `10` | `10` | +| SESSION_RATELIMIT_MAX | `10` | `10` | | TUNNEL_RATELIMIT_WINDOW | `60` | `60` | -| TUNNEL_RATELIMIT | `40` | `10` | +| TUNNEL_RATELIMIT_MAX | `40` | `10` | [*view details*](#limits) @@ -61,6 +61,7 @@ this document is not final and will expand over time. feel free to improve it! | YOUTUBE_SESSION_SERVER | `http://localhost:8080/` | | YOUTUBE_SESSION_INNERTUBE_CLIENT | `WEB_EMBEDDED` | | YOUTUBE_ALLOW_BETTER_AUDIO | `1` | +| ENABLE_DEPRECATED_YOUTUBE_HLS | `key` | [*view details*](#service-specific) @@ -106,11 +107,10 @@ comma-separated list which disables certain services from being used. the value is a string of cobalt-supported services. ### FORCE_LOCAL_PROCESSING -the value is a string: `never` (default), `session`, or `always`. - -when set to `session`, only requests from session (Bearer token) clients will be forced to use on-device processing. - -when set to `always`, all requests will be forced to use on-device processing, no matter the preference. +the value is a string: `never` (default), `session`, or `always`: +- when the var is not defined or set to `never`, all requests will be able to set a preference via `localProcessing` in POST requests. +- when set to `session`, only requests from session (Bearer token) clients will be forced to use on-device processing. +- when set to `always`, all requests will be forced to use on-device processing, no matter the preference. ### API_ENV_FILE the URL or local path to a `key=value`-style environment variable file. this is used for dynamically reloading environment variables. **not all environment variables are able to be updated by this.** (e.g. the ratelimiters are instantiated when starting cobalt, and cannot be changed) @@ -171,7 +171,7 @@ rate limit time window for session creation requests, in **seconds**. the value is a number. -### SESSION_RATELIMIT +### SESSION_RATELIMIT_MAX amount of session requests to be allowed within the time window of `SESSION_RATELIMIT_WINDOW`. the value is a number. @@ -181,7 +181,7 @@ rate limit time window for tunnel (proxy/stream) requests, in **seconds**. the value is a number. -### TUNNEL_RATELIMIT +### TUNNEL_RATELIMIT_MAX amount of tunnel requests to be allowed within the time window of `TUNNEL_RATELIMIT_WINDOW`. the value is a number. @@ -256,3 +256,9 @@ the value is a string. when set to `1`, cobalt will try to use higher quality audio if user requests it via `youtubeBetterAudio`. will negatively impact the rate limit of a secondary youtube client with a session. the value is a number, either `0` or `1`. + +### ENABLE_DEPRECATED_YOUTUBE_HLS +the value is a string: `never` (default), `key`, or `always`: +- when the var is not defined or set to `never`, `youtubeHLS` in POST requests will be ignored. +- when set to `key`, only requests from api-key clients will be able to use `youtubeHLS` in POST requests. +- when set to `always`, all requests will be able to use `youtubeHLS` in POST requests. diff --git a/docs/api.md b/docs/api.md index f093e148..6be29bc6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -81,15 +81,16 @@ all keys except for `url` are optional. value options are separated by `/`. | `localProcessing` | `boolean` | remux/transcode files locally instead of the server | `false` | #### service-specific options -| key | type | description/value | default | -|:-----------------------|:----------|:--------------------------------------------------|:--------| -| `youtubeVideoCodec` | `string` | `h264 / av1 / vp9` | `h264` | -| `youtubeDubLang` | `string` | any valid language code, such as: `en` or `zh-CN` | *none* | -| `convertGif` | `boolean` | convert twitter gifs to the actual GIF format | `true` | -| `allowH265` | `boolean` | allow H265/HEVC videos from tiktok/xiaohongshu | `false` | -| `tiktokFullAudio` | `boolean` | download the original sound used in a video | `false` | -| `youtubeBetterAudio` | `boolean` | prefer higher quality youtube audio if possible | `false` | -| `youtubeHLS` | `boolean` | use HLS formats when downloading from youtube | `false` | +| key | type | description/value | default | +|:------------------------|:----------|:--------------------------------------------------|:--------| +| `youtubeVideoCodec` | `string` | `h264 / av1 / vp9` | `h264` | +| `youtubeVideoContainer` | `string` | `auto / mp4 / webm / mkv` | `auto` | +| `youtubeDubLang` | `string` | any valid language code, such as: `en` or `zh-CN` | *none* | +| `convertGif` | `boolean` | convert twitter gifs to the actual GIF format | `true` | +| `allowH265` | `boolean` | allow H265/HEVC videos from tiktok/xiaohongshu | `false` | +| `tiktokFullAudio` | `boolean` | download the original sound used in a video | `false` | +| `youtubeBetterAudio` | `boolean` | prefer higher quality youtube audio if possible | `false` | +| `youtubeHLS` | `boolean` | use HLS formats when downloading from youtube | `false` | ### response body type: `application/json` @@ -120,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. @@ -142,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 | 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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32364730..6d67cc68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,8 +59,8 @@ importers: specifier: 1.0.3 version: 1.0.3 youtubei.js: - specifier: ^13.4.0 - version: 13.4.0 + specifier: ^14.0.0 + version: 14.0.0 zod: specifier: ^3.23.8 version: 3.23.8 @@ -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==} @@ -2181,8 +2181,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - youtubei.js@13.4.0: - resolution: {integrity: sha512-+fmIZU/dWAjsROONrASy1REwVpy6umAPVuoNLr/4iNmZXl84LyBef0n3hrd1Vn9035EuINToGyQcBmifwUEemA==} + youtubei.js@14.0.0: + resolution: {integrity: sha512-KAFttOw+9fwwBUvBc1T7KzMNBLczDOuN/dfote8BA9CABxgx8MPgV+vZWlowdDB6DnHjSUYppv+xvJ4VNBLK9A==} zimmerframe@1.1.2: resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} @@ -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: @@ -4035,7 +4035,7 @@ snapshots: yocto-queue@0.1.0: {} - youtubei.js@13.4.0: + youtubei.js@14.0.0: dependencies: '@bufbuild/protobuf': 2.2.5 jintr: 3.3.1 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/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", diff --git a/web/i18n/en/dialog.json b/web/i18n/en/dialog.json index 35a15a8c..44e92dab 100644 --- a/web/i18n/en/dialog.json +++ b/web/i18n/en/dialog.json @@ -15,14 +15,8 @@ "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?", - "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." } diff --git a/web/i18n/en/error/api.json b/web/i18n/en/error/api.json index 70c996e6..93158b61 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 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!", @@ -50,7 +51,7 @@ "content.post.private": "couldn't get anything about this post because it's from a private account. try a different link!", "content.post.age": "this post is age-restricted, so i can't access it anonymously. try again or try a different link!", - "youtube.no_matching_format": "youtube didn't return a valid video + audio format combo, either video or audio is missing. formats for this video may be re-encoding on youtube's side or something went wrong when parsing them. try enabling the hls option in video settings!", + "youtube.no_matching_format": "youtube didn't return any acceptable formats. cobalt may not support them or they're re-encoding on youtube's side. try again a bit later, but if this issue sticks, please report it!", "youtube.decipher": "youtube updated its decipher algorithm and i couldn't extract the info about the video. try again in a few seconds, but if this issue sticks, please report it!", "youtube.login": "couldn't get this video because youtube asked the processing instance to prove that it's not a bot. try again in a few seconds, but if it still doesn't work, please report this issue!", "youtube.token_expired": "couldn't get this video because the youtube token expired and wasn't refreshed. try again in a few seconds, but if it still doesn't work, please report this issue!", diff --git a/web/i18n/en/error/queue.json b/web/i18n/en/error/queue.json index 064dce5a..5df31caf 100644 --- a/web/i18n/en/error/queue.json +++ b/web/i18n/en/error/queue.json @@ -2,6 +2,8 @@ "no_final_file": "no final file output", "worker_didnt_start": "couldn't start a processing worker", + "generic_error": "processing worker crashed, see console for details", + "fetch.crashed": "fetch worker crashed, see console for details", "fetch.bad_response": "couldn't access the file tunnel", "fetch.no_file_reader": "couldn't write a file to cache", @@ -15,5 +17,6 @@ "ffmpeg.no_input_type": "the file's type isn't supported", "ffmpeg.crashed": "ffmpeg worker crashed, see console for details", "ffmpeg.no_render": "ffmpeg render is empty, something very odd happened", - "ffmpeg.no_args": "ffmpeg worker didn't get required arguments" + "ffmpeg.no_args": "ffmpeg worker didn't get required arguments", + "ffmpeg.no_audio_channel": "this video has no audio track, nothing to do" } diff --git a/web/i18n/en/settings.json b/web/i18n/en/settings.json index 7ab13fa0..dac6b62a 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", @@ -31,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.", @@ -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.\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", "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.", @@ -138,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/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", 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 { 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/components/save/CaptchaTooltip.svelte b/web/src/components/save/CaptchaTooltip.svelte index f114090d..af769e6f 100644 --- a/web/src/components/save/CaptchaTooltip.svelte +++ b/web/src/components/save/CaptchaTooltip.svelte @@ -40,7 +40,7 @@ } .tooltip-body { - max-width: 180px; + max-width: 190px; position: relative; pointer-events: none; diff --git a/web/src/components/settings/SettingsDropdown.svelte b/web/src/components/settings/SettingsDropdown.svelte index 07d4585a..9fe67666 100644 --- a/web/src/components/settings/SettingsDropdown.svelte +++ b/web/src/components/settings/SettingsDropdown.svelte @@ -42,7 +42,7 @@
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}`)}` diff --git a/web/src/lib/api/saving-handler.ts b/web/src/lib/api/saving-handler.ts index b2dfbbb4..94c0319e 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"; @@ -49,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"), - youtubeHLS: getSetting("save", "youtubeHLS"), + 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); 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), }; 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/lib/libav.ts b/web/src/lib/libav.ts index e888926a..64aa59b8 100644 --- a/web/src/lib/libav.ts +++ b/web/src/lib/libav.ts @@ -5,6 +5,12 @@ import EncodeLibAV from "@imput/libav.js-encode-cli"; import type { FfprobeData } from "fluent-ffmpeg"; import type { FFmpegProgressCallback, FFmpegProgressEvent, FFmpegProgressStatus, RenderParams } from "$lib/types/libav"; +const ua = navigator.userAgent.toLowerCase(); +const iPhone = ua.includes("iphone os"); +const iPad = !iPhone && ua.includes("mac os") && navigator.maxTouchPoints > 0; +const iOS = iPhone || iPad; +const modernIOS = iOS && Number(ua.match(/iphone os (\d+)_/)?.[1]) >= 18; + export default class LibAVWrapper { libav: Promise | null; concurrency: number; @@ -32,7 +38,7 @@ export default class LibAVWrapper { this.libav = constructor({ ...options, variant: undefined, - yesthreads: true, + yesthreads: !iOS || modernIOS, base: '/_libav' }); } diff --git a/web/src/lib/settings/audio-sub-language.ts b/web/src/lib/settings/audio-sub-language.ts new file mode 100644 index 00000000..3e9d23e4 --- /dev/null +++ b/web/src/lib/settings/audio-sub-language.ts @@ -0,0 +1,84 @@ +import { t } from "$lib/i18n/translations"; +import { get } from "svelte/store"; + +const languages = [ + // most popular languages are first, according to + // https://en.wikipedia.org/wiki/List_of_languages_by_number_of_native_speakers + "en", "es", "pt", "fr", "ru", + "zh", "vi", "hi", "bn", "ja", + + "af", "am", "ar", "as", "az", + "be", "bg", "bs", "ca", "cs", + "da", "de", "el", "et", "eu", + "fa", "fi", "fil", "gl", "gu", + "hr", "hu", "hy", "id", "is", + "it", "iw", "ka", "kk", "ko", + "km", "kn", "ky", "lo", "lt", + "lv", "mk", "ml", "mn", "mr", + "ms", "my", "no", "ne", "nl", + "or", "pa", "pl", "ro", "si", + "sk", "sl", "sq", "sr", "sv", + "sw", "ta", "te", "th", "tr", + "uk", "ur", "uz", "zh-Hans", + "zh-Hant", "zh-CN", "zh-HK", + "zh-TW", "zu" +]; + +export const youtubeDubLanguages = ["original", ...languages] as const; +export const subtitleLanguages = ["none", ...languages] as const; + +export type YoutubeDubLang = typeof youtubeDubLanguages[number]; +export type SubtitleLang = typeof subtitleLanguages[number]; + +const namedLanguages = ( + languages: typeof youtubeDubLanguages | typeof subtitleLanguages +) => { + return languages.reduce((obj, lang) => { + let name: string; + + switch (lang) { + case "original": + name = get(t)("settings.youtube.dub.original"); + break; + case "none": + name = get(t)("settings.subtitles.none"); + break; + default: { + let intlName = "unknown"; + try { + intlName = new Intl.DisplayNames([lang], { type: 'language' }).of(lang) || "unknown"; + } catch { /* */ }; + name = `${intlName} (${lang})`; + break; + } + } + + return { + ...obj, + [lang]: name, + }; + }, {}) as Record; +} + +export const namedYoutubeDubLanguages = () => { + return namedLanguages(youtubeDubLanguages); +} + +export const namedSubtitleLanguages = () => { + return namedLanguages(subtitleLanguages); +} + +export const getBrowserLanguage = (): YoutubeDubLang => { + if (typeof navigator === 'undefined') + return "original"; + + const browserLanguage = navigator.language as YoutubeDubLang; + if (youtubeDubLanguages.includes(browserLanguage)) + return browserLanguage; + + const shortened = browserLanguage.split('-')[0] as YoutubeDubLang; + if (youtubeDubLanguages.includes(shortened)) + return shortened; + + return "original"; +} diff --git a/web/src/lib/settings/defaults.ts b/web/src/lib/settings/defaults.ts index 54a96994..ce3114ec 100644 --- a/web/src/lib/settings/defaults.ts +++ b/web/src/lib/settings/defaults.ts @@ -3,7 +3,7 @@ import { defaultLocale } from "$lib/i18n/translations"; import type { CobaltSettings } from "$lib/types/settings"; const defaultSettings: CobaltSettings = { - schemaVersion: 5, + schemaVersion: 6, advanced: { debug: false, useWebCodecs: false, @@ -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, @@ -33,7 +34,9 @@ const defaultSettings: CobaltSettings = { tiktokFullAudio: false, convertGif: true, videoQuality: "1080", + subtitleLang: "none", youtubeVideoCodec: "h264", + youtubeVideoContainer: "auto", youtubeDubLang: "original", youtubeHLS: false, youtubeBetterAudio: false, diff --git a/web/src/lib/settings/migrate.ts b/web/src/lib/settings/migrate.ts index 3d372823..d033a6f9 100644 --- a/web/src/lib/settings/migrate.ts +++ b/web/src/lib/settings/migrate.ts @@ -5,8 +5,9 @@ import type { CobaltSettingsV3, CobaltSettingsV4, CobaltSettingsV5, + CobaltSettingsV6, } from "$lib/types/settings"; -import { getBrowserLanguage } from "$lib/settings/youtube-lang"; +import { getBrowserLanguage } from "$lib/settings/audio-sub-language"; type Migrator = (s: AllPartialSettingsWithSchema) => AllPartialSettingsWithSchema; @@ -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/settings/validate.ts b/web/src/lib/settings/validate.ts index 02467d2e..a711f9ae 100644 --- a/web/src/lib/settings/validate.ts +++ b/web/src/lib/settings/validate.ts @@ -1,5 +1,5 @@ import type { Optional } from '$lib/types/generic'; -import defaultSettings from './defaults' +import defaultSettings from '$lib/settings/defaults'; import { downloadModeOptions, filenameStyleOptions, @@ -9,7 +9,7 @@ import { youtubeVideoCodecOptions, type PartialSettings, } from '$lib/types/settings'; -import { youtubeLanguages } from './youtube-lang'; +import { youtubeDubLanguages } from '$lib/settings/audio-sub-language'; function validateTypes(input: unknown, reference = defaultSettings as unknown) { if (typeof input === 'undefined') @@ -81,7 +81,7 @@ export function validateSettings(settings: PartialSettings) { [ settings?.save?.videoQuality , videoQualityOptions ], [ settings?.save?.youtubeVideoCodec, youtubeVideoCodecOptions ], [ settings?.save?.savingMethod , savingMethodOptions ], - [ settings?.save?.youtubeDubLang , youtubeLanguages ] + [ settings?.save?.youtubeDubLang , youtubeDubLanguages ] ]) ); } diff --git a/web/src/lib/settings/youtube-lang.ts b/web/src/lib/settings/youtube-lang.ts deleted file mode 100644 index 9c470547..00000000 --- a/web/src/lib/settings/youtube-lang.ts +++ /dev/null @@ -1,115 +0,0 @@ -export const youtubeLanguages = [ - "original", - "af", - "am", - "ar", - "as", - "az", - "be", - "bg", - "bn", - "bs", - "ca", - "cs", - "da", - "de", - "el", - "en", - "es", - "et", - "eu", - "fa", - "fi", - "fil", - "fr", - "gl", - "gu", - "hi", - "hr", - "hu", - "hy", - "id", - "is", - "it", - "iw", - "ja", - "ka", - "kk", - "km", - "kn", - "ko", - "ky", - "lo", - "lt", - "lv", - "mk", - "ml", - "mn", - "mr", - "ms", - "my", - "no", - "ne", - "nl", - "or", - "pa", - "pl", - "pt", - "ro", - "ru", - "si", - "sk", - "sl", - "sq", - "sr", - "sv", - "sw", - "ta", - "te", - "th", - "tr", - "uk", - "ur", - "uz", - "vi", - "zh", - "zh-Hans", - "zh-Hant", - "zh-CN", - "zh-HK", - "zh-TW", - "zu" -] as const; - -export type YoutubeLang = typeof youtubeLanguages[number]; - -export const namedYoutubeLanguages = () => { - return youtubeLanguages.reduce((obj, lang) => { - const intlName = new Intl.DisplayNames([lang], { type: 'language' }).of(lang); - - let name = `${intlName} (${lang})`; - if (lang === "original") { - name = lang; - } - - return { - ...obj, - [lang]: name, - }; - }, {}) as Record; -} - -export const getBrowserLanguage = (): YoutubeLang => { - if (typeof navigator === 'undefined') - return "original"; - - const browserLanguage = navigator.language as YoutubeLang; - if (youtubeLanguages.includes(browserLanguage)) - return browserLanguage; - - const shortened = browserLanguage.split('-')[0] as YoutubeLang; - if (youtubeLanguages.includes(shortened)) - return shortened; - - return "original"; -} 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..d745cb34 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); @@ -41,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; diff --git a/web/src/lib/task-manager/queue.ts b/web/src/lib/task-manager/queue.ts index bd79269f..2d439333 100644 --- a/web/src/lib/task-manager/queue.ts +++ b/web/src/lib/task-manager/queue.ts @@ -4,28 +4,28 @@ 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"; -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; } } 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], @@ -54,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"]; @@ -71,6 +63,13 @@ const makeRemuxArgs = (info: CobaltLocalProcessingResponse) => { 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) : []) ); @@ -83,11 +82,27 @@ const makeAudioArgs = (info: CobaltLocalProcessingResponse) => { return; } - const ffargs = [ - "-vn", + const ffargs = []; + + if (info.audio.cover && info.audio.format === "mp3") { + ffargs.push( + "-map", "0", + "-map", "1", + ...(info.audio.cropCover ? [ + "-c:v", "mjpeg", + "-vf", "scale=-1:720,crop=720:720", + ] : [ + "-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"); @@ -140,7 +155,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 +164,7 @@ export const createSavePipeline = ( for (const tunnel of tunnels) { pipeline.push({ worker: "fetch", - workerId: crypto.randomUUID(), + workerId: uuid(), parentId, workerArgs: { url: tunnel, @@ -157,43 +172,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: crypto.randomUUID(), - 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, @@ -203,7 +220,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/task-manager/workers/ffmpeg.ts b/web/src/lib/task-manager/workers/ffmpeg.ts index 7568fb8a..cfb4868d 100644 --- a/web/src/lib/task-manager/workers/ffmpeg.ts +++ b/web/src/lib/task-manager/workers/ffmpeg.ts @@ -64,6 +64,14 @@ const ffmpeg = async (variant: string, files: File[], args: string[], output: Fi return error("queue.ffmpeg.no_input_format"); } + // handle the edge case when a video doesn't have an audio track + // but user still tries to extract it + if (files.length === 1 && file_info.streams?.length === 1) { + if (output.type?.startsWith("audio") && file_info.streams[0].codec_type !== "audio") { + return error("queue.ffmpeg.no_audio_channel"); + } + } + self.postMessage({ cobaltFFmpegWorker: { progress: { diff --git a/web/src/lib/types/api.ts b/web/src/lib/types/api.ts index 13d2b8b7..f127d02e 100644 --- a/web/src/lib/types/api.ts +++ b/web/src/lib/types/api.ts @@ -52,14 +52,15 @@ export const CobaltFileMetadataKeys = [ 'artist', 'album_artist', 'track', - 'date' + 'date', + 'sublanguage', ]; 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, @@ -72,12 +73,15 @@ export type CobaltLocalProcessingResponse = { type: string, // mimetype filename: string, metadata?: CobaltFileMetadata, + subtitles?: boolean, }, audio?: { copy: boolean, format: string, bitrate: string, + cover?: boolean, + cropCover?: boolean, }, isHLS?: boolean, diff --git a/web/src/lib/types/settings.ts b/web/src/lib/types/settings.ts index 37454183..1d184eec 100644 --- a/web/src/lib/types/settings.ts +++ b/web/src/lib/types/settings.ts @@ -3,15 +3,17 @@ import type { CobaltSettingsV2 } from "$lib/types/settings/v2"; import type { CobaltSettingsV3 } from "$lib/types/settings/v3"; import type { CobaltSettingsV4 } from "$lib/types/settings/v4"; import type { CobaltSettingsV5 } from "$lib/types/settings/v5"; +import type { CobaltSettingsV6 } from "$lib/types/settings/v6"; export * from "$lib/types/settings/v2"; export * from "$lib/types/settings/v3"; export * from "$lib/types/settings/v4"; export * from "$lib/types/settings/v5"; +export * from "$lib/types/settings/v6"; -export type CobaltSettings = CobaltSettingsV5; +export type CobaltSettings = CobaltSettingsV6; -export type AnyCobaltSettings = CobaltSettingsV4 | CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings; +export type AnyCobaltSettings = CobaltSettingsV5 | CobaltSettingsV4 | CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings; export type PartialSettings = RecursivePartial; diff --git a/web/src/lib/types/settings/v3.ts b/web/src/lib/types/settings/v3.ts index 7d02f2da..31e223c9 100644 --- a/web/src/lib/types/settings/v3.ts +++ b/web/src/lib/types/settings/v3.ts @@ -1,9 +1,9 @@ -import type { YoutubeLang } from "$lib/settings/youtube-lang"; +import type { YoutubeDubLang } from "$lib/settings/audio-sub-language"; import { type CobaltSettingsV2 } from "$lib/types/settings/v2"; export type CobaltSettingsV3 = Omit & { schemaVersion: 3, save: Omit & { - youtubeDubLang: YoutubeLang; + youtubeDubLang: YoutubeDubLang; }; }; diff --git a/web/src/lib/types/settings/v6.ts b/web/src/lib/types/settings/v6.ts new file mode 100644 index 00000000..0a933e67 --- /dev/null +++ b/web/src/lib/types/settings/v6.ts @@ -0,0 +1,14 @@ +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: Omit & { + localProcessing: typeof localProcessingOptions[number], + youtubeVideoContainer: typeof youtubeVideoContainerOptions[number]; + subtitleLang: SubtitleLang, + }, +}; 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]; diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index 609f651e..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 @@ -26,3 +33,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('-'); +} diff --git a/web/src/routes/settings/audio/+page.svelte b/web/src/routes/settings/audio/+page.svelte index 932cfc20..06b513d7 100644 --- a/web/src/routes/settings/audio/+page.svelte +++ b/web/src/routes/settings/audio/+page.svelte @@ -1,7 +1,7 @@ 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} 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 @@ + + + + + import env from "$lib/env"; 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"; @@ -11,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,20 +56,42 @@ - + + {#each youtubeVideoContainerOptions as value} + + {value} + + {/each} + +{#if env.ENABLE_DEPRECATED_YOUTUBE_HLS} + + + +{/if} +