From 339a932e7fa402855ce5f8bf3139d1a8ccccbb01 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 26 Jul 2024 09:50:27 +0600 Subject: [PATCH 01/10] package: update youtubei.js --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4917de2d..34d4ec7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "set-cookie-parser": "2.6.0", "undici": "^5.19.1", "url-pattern": "1.0.3", - "youtubei.js": "^10.1.0" + "youtubei.js": "^10.2.0" }, "engines": { "node": ">=18" @@ -1123,12 +1123,13 @@ } }, "node_modules/youtubei.js": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-10.1.0.tgz", - "integrity": "sha512-MokZMAnpWH11VYvWuW6qjPiiPmgRl5rfDgPQOpif9qXcVHoVw1hi8ePuRSD0AZSZ+uvWGe8rvas2dzp+Jv5JKQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-10.2.0.tgz", + "integrity": "sha512-JLKW9AHQ1qrTwBbre1aDkH8UJFmNcc4+kOSaVou5jSY7AzfFPFJK0yvX6afnLst0UVC9wfXHrLiNx93sutVErA==", "funding": [ "https://github.com/sponsors/LuanRT" ], + "license": "MIT", "dependencies": { "jintr": "^2.0.0", "tslib": "^2.5.0", diff --git a/package.json b/package.json index bcba1894..9a4a4063 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "set-cookie-parser": "2.6.0", "undici": "^5.19.1", "url-pattern": "1.0.3", - "youtubei.js": "^10.1.0" + "youtubei.js": "^10.2.0" }, "optionalDependencies": { "freebind": "^0.2.2" From b718b79ccd0a2afaf2a2a67f6286387f3019a66f Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 26 Jul 2024 09:52:55 +0600 Subject: [PATCH 02/10] package: bump version to 7.15 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9a4a4063..58fdec83 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cobalt", "description": "save what you love", - "version": "7.14.6", + "version": "7.15", "author": "imput", "exports": "./src/cobalt.js", "type": "module", From b308db4a5964c995f2d5f49ceb14a9887464830a Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 26 Jul 2024 09:55:41 +0600 Subject: [PATCH 03/10] package: update lock --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 34d4ec7d..87e11661 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cobalt", - "version": "7.14.6", + "version": "7.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cobalt", - "version": "7.14.6", + "version": "7.15", "license": "AGPL-3.0", "dependencies": { "content-disposition-header": "0.6.0", From b2385873240f2fd6897fe2e5078065af4f2d1b1b Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 26 Jul 2024 09:56:22 +0600 Subject: [PATCH 04/10] tests: allow rutube yappy test to fail --- src/util/tests.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/util/tests.json b/src/util/tests.json index 1093d75c..d447b348 100644 --- a/src/util/tests.json +++ b/src/util/tests.json @@ -1065,6 +1065,7 @@ }, { "name": "yappy", "url": "https://rutube.ru/yappy/c8c32bf7aee04412837656ea26c2b25b/", + "canFail": true, "params": {}, "expected": { "code": 200, From 0895c695e7e94fa9f5837eef5517d2cd0ff4afe7 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 26 Jul 2024 13:45:00 +0600 Subject: [PATCH 05/10] repo: use global org funding file --- .github/FUNDING.yml | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 89951fe0..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -custom: https://boosty.to/wukko/donate From 707115758007fe80433a41026121554096ffcfb3 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 26 Jul 2024 19:54:19 +0600 Subject: [PATCH 06/10] youtube: fix audio track selection for dubbed videos & clean up closes #642 --- src/modules/processing/services/youtube.js | 109 ++++++++++++++------- 1 file changed, 73 insertions(+), 36 deletions(-) diff --git a/src/modules/processing/services/youtube.js b/src/modules/processing/services/youtube.js index 7beca2b4..2a0d25c5 100644 --- a/src/modules/processing/services/youtube.js +++ b/src/modules/processing/services/youtube.js @@ -1,25 +1,27 @@ -import { Innertube, Session } from 'youtubei.js'; -import { env } from '../../config.js'; -import { cleanString } from '../../sub/utils.js'; -import { fetch } from 'undici' -import { getCookie, updateCookieValues } from '../cookie/manager.js' +import { fetch } from "undici"; + +import { Innertube, Session } from "youtubei.js"; + +import { env } from "../../config.js"; +import { cleanString } from "../../sub/utils.js"; +import { getCookie, updateCookieValues } from "../cookie/manager.js"; const ytBase = Innertube.create().catch(e => e); const codecMatch = { h264: { - codec: "avc1", - aCodec: "mp4a", + videoCodec: "avc1", + audioCodec: "mp4a", container: "mp4" }, av1: { - codec: "av01", - aCodec: "mp4a", + videoCodec: "av01", + audioCodec: "mp4a", container: "mp4" }, vp9: { - codec: "vp9", - aCodec: "opus", + videoCodec: "vp9", + audioCodec: "opus", container: "webm" } } @@ -94,11 +96,16 @@ const cloneInnertube = async (customFetch) => { export default async function(o) { const yt = await cloneInnertube( - (input, init) => fetch(input, { ...init, dispatcher: o.dispatcher }) + (input, init) => fetch(input, { + ...init, + dispatcher: o.dispatcher + }) ); - let info, isDubbed, format = o.format || "h264"; - let quality = o.quality === "max" ? "9000" : o.quality; // 9000(p) - max quality + const quality = o.quality === "max" ? "9000" : o.quality; + + let info, isDubbed, + format = o.format || "h264"; function qual(i) { if (!i.quality_label) { @@ -121,6 +128,7 @@ export default async function(o) { if (!info) return { error: 'ErrorCantConnectToServiceAPI' }; const playability = info.playability_status; + const basicInfo = info.basic_info; if (playability.status === 'LOGIN_REQUIRED') { if (playability.reason.endsWith('bot')) { @@ -135,11 +143,11 @@ export default async function(o) { } if (playability.status !== 'OK') return { error: 'ErrorYTUnavailable' }; - if (info.basic_info.is_live) return { error: 'ErrorLiveVideo' }; + if (basicInfo.is_live) return { error: 'ErrorLiveVideo' }; // return a critical error if returned video is "Video Not Available" // or a similar stub by youtube - if (info.basic_info.id !== o.id) { + if (basicInfo.id !== o.id) { return { error: 'ErrorCantConnectToServiceAPI', critical: true @@ -148,11 +156,16 @@ export default async function(o) { let bestQuality, hasAudio; - const filterByCodec = (formats) => formats.filter(e => - e.mime_type.includes(codecMatch[format].codec) || e.mime_type.includes(codecMatch[format].aCodec) - ).sort((a, b) => Number(b.bitrate) - Number(a.bitrate)); + const filterByCodec = (formats) => + formats + .filter(e => + e.mime_type.includes(codecMatch[format].videoCodec) + || e.mime_type.includes(codecMatch[format].audioCodec) + ) + .sort((a, b) => Number(b.bitrate) - Number(a.bitrate)); let adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats); + if (adaptive_formats.length === 0 && format === "vp9") { format = "h264" adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats) @@ -162,27 +175,43 @@ export default async function(o) { hasAudio = adaptive_formats.find(i => i.has_audio && i.content_length); if (bestQuality) bestQuality = qual(bestQuality); - if (!bestQuality && !o.isAudioOnly || !hasAudio) return { error: 'ErrorYTTryOtherCodec' }; - if (info.basic_info.duration > env.durationLimit) return { error: ['ErrorLengthLimit', env.durationLimit / 60] }; - let checkBestAudio = (i) => (i.has_audio && !i.has_video), - audio = adaptive_formats.find(i => checkBestAudio(i) && !i.is_dubbed); + if (!bestQuality && !o.isAudioOnly || !hasAudio) + return { error: 'ErrorYTTryOtherCodec' }; + + if (basicInfo.duration > env.durationLimit) + return { error: ['ErrorLengthLimit', env.durationLimit / 60] }; + + const checkBestAudio = (i) => (i.has_audio && !i.has_video); + + let audio = adaptive_formats.find(i => + checkBestAudio(i) && i.is_original + ); if (o.dubLang) { let dubbedAudio = adaptive_formats.find(i => - checkBestAudio(i) && i.language === o.dubLang && i.audio_track && !i.audio_track.audio_is_default - ); + checkBestAudio(i) + && i.language === o.dubLang + && i.audio_track + ) + if (dubbedAudio) { audio = dubbedAudio; - isDubbed = true + isDubbed = true; } } - let fileMetadata = { - title: cleanString(info.basic_info.title.trim()), - artist: cleanString(info.basic_info.author.replace("- Topic", "").trim()), + + if (!audio) { + audio = adaptive_formats.find(i => checkBestAudio(i)); } - if (info.basic_info.short_description && info.basic_info.short_description.startsWith("Provided to YouTube by")) { - let descItems = info.basic_info.short_description.split("\n\n"); + + let fileMetadata = { + title: cleanString(basicInfo.title.trim()), + artist: cleanString(basicInfo.author.replace("- Topic", "").trim()), + } + + if (basicInfo?.short_description?.startsWith("Provided to YouTube by")) { + let descItems = basicInfo.short_description.split("\n\n"); fileMetadata.album = descItems[2]; fileMetadata.copyright = descItems[3]; if (descItems[4].startsWith("Released on:")) { @@ -204,13 +233,17 @@ export default async function(o) { urls: audio.decipher(yt.session.player), filenameAttributes: filenameAttributes, fileMetadata: fileMetadata, - bestAudio: format === "h264" ? 'm4a' : 'opus' + bestAudio: format === "h264" ? "m4a" : "opus" } + const matchingQuality = Number(quality) > Number(bestQuality) ? bestQuality : quality, - checkSingle = i => qual(i) === matchingQuality && i.mime_type.includes(codecMatch[format].codec), - checkRender = i => qual(i) === matchingQuality && i.has_video && !i.has_audio; + checkSingle = i => + qual(i) === matchingQuality && i.mime_type.includes(codecMatch[format].videoCodec), + checkRender = i => + qual(i) === matchingQuality && i.has_video && !i.has_audio; let match, type, urls; + if (!o.isAudioOnly && !o.isAudioMuted && format === 'h264') { match = info.streaming_data.formats.find(checkSingle); type = "bridge"; @@ -218,10 +251,14 @@ export default async function(o) { } const video = adaptive_formats.find(checkRender); + if (!match && video) { match = video; type = "render"; - urls = [video.decipher(yt.session.player), audio.decipher(yt.session.player)]; + urls = [ + video.decipher(yt.session.player), + audio.decipher(yt.session.player) + ] } if (match) { @@ -232,7 +269,7 @@ export default async function(o) { return { type, urls, - filenameAttributes, + filenameAttributes, fileMetadata } } From 57051968f3f35f271929b486c2fc22eadc949da8 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 26 Jul 2024 20:18:59 +0600 Subject: [PATCH 07/10] youtube: better audio checking --- src/modules/processing/services/youtube.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/processing/services/youtube.js b/src/modules/processing/services/youtube.js index 2a0d25c5..5aa201ce 100644 --- a/src/modules/processing/services/youtube.js +++ b/src/modules/processing/services/youtube.js @@ -176,7 +176,7 @@ export default async function(o) { if (bestQuality) bestQuality = qual(bestQuality); - if (!bestQuality && !o.isAudioOnly || !hasAudio) + if ((!bestQuality && !o.isAudioOnly) || !hasAudio) return { error: 'ErrorYTTryOtherCodec' }; if (basicInfo.duration > env.durationLimit) @@ -227,7 +227,7 @@ export default async function(o) { youtubeDubName: isDubbed ? o.dubLang : false } - if (hasAudio && o.isAudioOnly) return { + if (audio && o.isAudioOnly) return { type: "render", isAudioOnly: true, urls: audio.decipher(yt.session.player), @@ -252,7 +252,7 @@ export default async function(o) { const video = adaptive_formats.find(checkRender); - if (!match && video) { + if (!match && video && audio) { match = video; type = "render"; urls = [ From dad13548e6e176a48793f91e56a149da1ba96b32 Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 26 Jul 2024 21:17:37 +0600 Subject: [PATCH 08/10] twitter: add support for downloading photos --- src/modules/processing/services/twitter.js | 27 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/modules/processing/services/twitter.js b/src/modules/processing/services/twitter.js index 36a8669b..30681636 100644 --- a/src/modules/processing/services/twitter.js +++ b/src/modules/processing/services/twitter.js @@ -147,7 +147,6 @@ export default async function({ id, index, toGif, dispatcher }) { } let media = (repostedTweet?.media || baseTweet?.extended_entities?.media); - media = media?.filter(m => m.video_info?.variants?.length); // check if there's a video at given index (/video/) if (index >= 0 && index < media?.length) { @@ -159,18 +158,38 @@ export default async function({ id, index, toGif, dispatcher }) { case 0: return { error: 'ErrorNoVideosInTweet' }; case 1: + if (media[0].type === "photo") { + return { + type: "normal", + isPhoto: true, + urls: `${media[0].media_url_https}?name=4096x4096` + } + } + return { type: needsFixing(media[0]) ? "remux" : "normal", urls: bestQuality(media[0].video_info.variants), filename: `twitter_${id}.mp4`, audioFilename: `twitter_${id}_audio`, isGif: media[0].type === "animated_gif" - }; + } default: const picker = media.map((content, i) => { + if (content.type === "photo") { + let url = `${content.media_url_https}?name=4096x4096`; + return { + type: "photo", + url, + thumb: url, + } + } + let url = bestQuality(content.video_info.variants); const shouldRenderGif = content.type === 'animated_gif' && toGif; + let type = "video"; + if (shouldRenderGif) type = "gif"; + if (needsFixing(content) || shouldRenderGif) { url = createStream({ service: 'twitter', @@ -181,9 +200,9 @@ export default async function({ id, index, toGif, dispatcher }) { } return { - type: 'video', + type, url, - thumb: content.media_url_https, + thumb: content.media_url_https } }); return { picker }; From 9d4f7e666f8d67e6ce20d1053b6ef372d055aaaa Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 26 Jul 2024 21:22:24 +0600 Subject: [PATCH 09/10] tests: add twitter image tests --- src/util/tests.json | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/util/tests.json b/src/util/tests.json index d447b348..ab6a1e6a 100644 --- a/src/util/tests.json +++ b/src/util/tests.json @@ -11,8 +11,7 @@ "code": 200, "status": "redirect" } - }, - { + }, { "name": "video with mobile web mediaviewer", "url": "https://twitter.com/X/status/1697304622749086011/mediaViewer?currentTweet=1697304622749086011¤tTweetUser=X¤tTweet=1697304622749086011¤tTweetUser=X", "params": {}, @@ -137,6 +136,22 @@ "code": 200, "status": "redirect" } + }, { + "name": "post with 1 image", + "url": "https://x.com/PopCrave/status/1815960083475423235", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, { + "name": "post with 4 images", + "url": "https://x.com/PopCrave/status/1816260887147114696", + "params": {}, + "expected": { + "code": 200, + "status": "picker" + } }, { "name": "retweeted video, isAudioOnly", "url": "https://twitter.com/hugekiwinuts/status/1618671150829309953?s=46&t=gItGzgwGQQJJaJrO6qc1Pg", From f8a28c6c5f2fd0cee45daf3fbdd521d22f6a0e7f Mon Sep 17 00:00:00 2001 From: wukko Date: Fri, 26 Jul 2024 22:02:59 +0600 Subject: [PATCH 10/10] docs/api: update info about picker item types --- docs/api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index b63c9216..6cd66bba 100644 --- a/docs/api.md +++ b/docs/api.md @@ -49,9 +49,9 @@ item type: `object` | key | type | variables | description | |:--------|:---------|:--------------------------------------------------------|:---------------------------------------| -| `type` | `string` | `video` | used only if `pickerType`is `various`. | +| `type` | `string` | `video / photo / gif` | used only if `pickerType` is `various` | | `url` | `string` | direct link to a file or a link to cobalt's live render | | -| `thumb` | `string` | item thumbnail that's displayed in the picker | used only for `video` type. | +| `thumb` | `string` | item thumbnail that's displayed in the picker | used for `video` and `gif` types | ## GET: `/api/stream` cobalt's live render (or stream) endpoint. usually, you will receive a url to this endpoint