From e721cf987859ab609606b0acbd195908a65761d2 Mon Sep 17 00:00:00 2001 From: wukko Date: Sat, 16 Sep 2023 23:38:07 +0600 Subject: [PATCH] support for rutube, fixes, accommodations for multi lang --- package.json | 3 +- src/config.json | 31 ++++++----- src/front/cobalt.css | 4 ++ src/front/emoji/newspaper.svg | 5 ++ src/localization/languages/en.json | 3 +- src/localization/languages/ru.json | 8 ++- src/modules/emoji.js | 3 +- src/modules/pageRender/elements.js | 12 +++- src/modules/pageRender/page.js | 28 ++++------ src/modules/processing/match.js | 8 +++ src/modules/processing/services/rutube.js | 30 ++++++++++ src/modules/processing/servicesConfig.json | 6 ++ .../processing/servicesPatternTesters.js | 2 + src/modules/stream/types.js | 2 +- src/modules/sub/utils.js | 2 +- src/test/tests.json | 55 +++++++++++++++++++ 16 files changed, 164 insertions(+), 38 deletions(-) create mode 100644 src/front/emoji/newspaper.svg create mode 100644 src/modules/processing/services/rutube.js diff --git a/package.json b/package.json index 304fa9eb..fb1abc15 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cobalt", "description": "save what you love", - "version": "7.4", + "version": "7.5", "author": "wukko", "exports": "./src/cobalt.js", "type": "module", @@ -30,6 +30,7 @@ "express": "^4.18.1", "express-rate-limit": "^6.3.0", "ffmpeg-static": "^5.1.0", + "hls-parser": "^0.10.7", "nanoid": "^4.0.2", "node-cache": "^5.1.2", "set-cookie-parser": "2.6.0", diff --git a/src/config.json b/src/config.json index 1537223b..10e3286d 100644 --- a/src/config.json +++ b/src/config.json @@ -7,17 +7,22 @@ "link": "https://wukko.me/", "contact": "https://wukko.me/contacts", "support": { - "twitter": { - "url": "https://twitter.com/justusecobalt", - "handle": "@justusecobalt" - }, - "mastodon": { - "url": "https://wetdry.world/@cobalt", - "handle": "@cobalt@wetdry.world" - }, - "discord": { - "url": "https://discord.gg/pQPt8HBUPu", - "handle": "cobalt community server" + "default": { + "twitter": { + "emoji": "🐦", + "url": "https://twitter.com/justusecobalt", + "handle": "@justusecobalt" + }, + "mastodon": { + "emoji": "🐘", + "url": "https://wetdry.world/@cobalt", + "handle": "@cobalt@wetdry.world" + }, + "discord": { + "emoji": "👾", + "url": "https://discord.gg/pQPt8HBUPu", + "handle": "cobalt community server" + } } } }, @@ -40,6 +45,7 @@ "02-17": "😺", "02-22": "😺", "03-01": "😺", + "03-08": "💪", "05-26": "🎂", "08-08": "😺", "08-26": "🐶", @@ -59,8 +65,7 @@ "12-28": "🎄", "12-29": "🎄", "12-30": "🎄", - "12-31": "🎄", - "03-08": "💪" + "12-31": "🎄" }, "supportedAudio": ["mp3", "ogg", "wav", "opus"], "ffmpegArgs": { diff --git a/src/front/cobalt.css b/src/front/cobalt.css index 3c838ef2..e0e5322e 100644 --- a/src/front/cobalt.css +++ b/src/front/cobalt.css @@ -604,6 +604,10 @@ button:active, line-height: 1.3rem!important; color: var(--accent-subtext); } +.explanation.embedded { + margin-top: 0.825rem; + margin-bottom: 0.825rem; +} .subtext { color: var(--accent-subtext); } diff --git a/src/front/emoji/newspaper.svg b/src/front/emoji/newspaper.svg new file mode 100644 index 00000000..ebe0b5fd --- /dev/null +++ b/src/front/emoji/newspaper.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/localization/languages/en.json b/src/localization/languages/en.json index 7025f222..a0dc8f60 100644 --- a/src/localization/languages/en.json +++ b/src/localization/languages/en.json @@ -143,6 +143,7 @@ "NewDomainWelcomeTitle": "hey there!", "NewDomainWelcome": "cobalt is moving! same features, same owner, simply a more rememberable domain. and still no ads.\n\ncobalt.tools is the new main domain, aka where you are now. make sure to update your bookmarks and reinstall the web app!", "DataTransferSuccess": "btw, your settings have been transferred automatically :)", - "DataTransferError": "something went wrong when transferring your preferences. you'll have to open settings and configure cobalt by hand." + "DataTransferError": "something went wrong when transferring your preferences. you'll have to open settings and configure cobalt by hand.", + "SupportNotAffiliated": "cobalt is not affiliated with any services listed above." } } diff --git a/src/localization/languages/ru.json b/src/localization/languages/ru.json index 7bd78e8b..ef9ddc43 100644 --- a/src/localization/languages/ru.json +++ b/src/localization/languages/ru.json @@ -102,8 +102,8 @@ "CollapseServices": "что поддерживается?", "CollapseSupport": "поддержка и исходный код", "CollapsePrivacy": "политика конфиденциальности", - "ServicesNote": "этот список далеко не финальный и постоянно пополняется. заглядывай сюда почаще, тогда точно будешь знать, что поддерживается!", - "FollowSupport": "оставайтесь на связи с кобальтом для новостей, поддержки, участия в опросах, и многого другого:", + "ServicesNote": "этот список далеко не финальный и постоянно пополняется, заглядывай сюда почаще!", + "FollowSupport": "подписывайся на соц.сети кобальта для новостей, поддержки, участия в опросах, и многого другого:", "SupportNote": "так как я занимаюсь разработкой и поддержкой в одиночку, время ожидания ответа может достигать нескольких часов. но я отвечаю всем, так что не стесняйся.", "SourceCode": "пиши о проблемах, шарься в исходнике, или же форкай репозиторий:", "PrivacyPolicy": "политика конфиденциальности кобальта довольно проста: никакие данные о тебе никогда не собираются и не хранятся. нуль, ноль, нада, ничего.\nто, что ты скачиваешь, - твоё личное дело, а не чьё-либо ещё.\n\nесли твоей загрузке требуется лайв рендер, то некоторые неотслеживаемые данные временно держатся в ОЗУ сервера. это необходимо для работы данной функции.\n\nв этом случае данные о запрошенном контенте хранятся в течение 20 секунд. по истечении этого времени всё стирается. ни у кого (даже у меня) нет доступа к временно хранящимся данным, так как официальная кодовая база кобальта не предусматривает возможности их чтения вне функций обработки.\n\nты всегда можешь посмотреть исходный код кобальта и убедиться, что всё так, как заявлено.", @@ -144,6 +144,8 @@ "NewDomainWelcomeTitle": "привет!", "NewDomainWelcome": "кобальт переезжает! те же функции, тот же владелец, просто более запоминающийся домен. по-прежнему без рекламы.\n\ncobalt.tools - новый основной домен, т.е. где ты сейчас находишься. не забудь обновить закладки и переустановить веб-приложение!", "DataTransferSuccess": "кстати, твои настройки были перенесены автоматически :)", - "DataTransferError": "при переносе настроек что-то пошло не так. придётся зайти в настройки и настроить кобальт вручную." + "DataTransferError": "при переносе настроек что-то пошло не так. придётся зайти в настройки и настроить кобальт вручную.", + "SupportNotAffiliated": "кобальт не аффилирован ни с одним из перечисленных выше сервисов.", + "SupportMetaNoticeRU": "деятельность meta platforms inc. (владелец instagram) запрещена на территории россии." } } diff --git a/src/modules/emoji.js b/src/modules/emoji.js index 068e6bdb..04e053f0 100644 --- a/src/modules/emoji.js +++ b/src/modules/emoji.js @@ -34,7 +34,8 @@ const names = { "⌨": "keyboard", "📑": "boring_document", "🧮": "abacus", - "😸": "cat_grin" + "😸": "cat_grin", + "📰": "newspaper" } let sizing = { 18: 0.8, diff --git a/src/modules/pageRender/elements.js b/src/modules/pageRender/elements.js index 824814e6..65f96699 100644 --- a/src/modules/pageRender/elements.js +++ b/src/modules/pageRender/elements.js @@ -1,4 +1,4 @@ -import { celebrations } from "../config.js"; +import { authorInfo, celebrations } from "../config.js"; import emoji from "../emoji.js"; export const backButtonSVG = ` @@ -160,6 +160,16 @@ export function popupWithBottomButtons(obj) { export function socialLink(emji, name, handle, url) { return `` } +export function socialLinks(lang) { + let links = authorInfo.support[lang] ? authorInfo.support[lang] : authorInfo.support.default; + let r = ``; + for (let i in links) { + r += socialLink( + emoji(links[i].emoji), i, links[i].handle, links[i].url + ) + } + return r +} export function settingsCategory(obj) { return `
${obj.title ? obj.title : obj.name}
diff --git a/src/modules/pageRender/page.js b/src/modules/pageRender/page.js index 006a8849..57367e6f 100644 --- a/src/modules/pageRender/page.js +++ b/src/modules/pageRender/page.js @@ -1,4 +1,4 @@ -import { checkbox, collapsibleList, explanation, footerButtons, multiPagePopup, popup, popupWithBottomButtons, sep, settingsCategory, switcher, socialLink, urgentNotice, keyboardShortcuts, webLoc } from "./elements.js"; +import { checkbox, collapsibleList, explanation, footerButtons, multiPagePopup, popup, popupWithBottomButtons, sep, settingsCategory, switcher, socialLink, socialLinks, urgentNotice, keyboardShortcuts, webLoc } from "./elements.js"; import { services as s, authorInfo, version, repo, donations, supportedAudio } from "../config.js"; import { getCommitInfo } from "../sub/currentCommit.js"; import loc from "../../localization/manager.js"; @@ -71,7 +71,7 @@ export default function(obj) { - + @@ -99,7 +99,11 @@ export default function(obj) { text: collapsibleList([{ name: "services", title: `${emoji("🔗")} ${t("CollapseServices")}`, - body: `${enabledServices}

${t("ServicesNote")}` + body: `${enabledServices}` + + `
${t("SupportNotAffiliated")}` + + `${obj.lang === "ru" ? `
${t("SupportMetaNoticeRU")}` : ''}` + + `
` + + `${t("ServicesNote")}` }, { name: "keyboard", title: `${emoji("⌨")} ${t("CollapseKeyboard")}`, @@ -143,19 +147,11 @@ export default function(obj) { name: "support", title: `${emoji("❤️‍🩹")} ${t("CollapseSupport")}`, body: - `${t("SupportSelfTroubleshooting")}

- ${t("FollowSupport")}
- ${socialLink( - emoji("🐦"), "twitter", authorInfo.support.twitter.handle, authorInfo.support.twitter.url - )} - ${socialLink( - emoji("👾"), "discord", authorInfo.support.discord.handle, authorInfo.support.discord.url - )} - ${socialLink( - emoji("🐘"), "mastodon", authorInfo.support.mastodon.handle, authorInfo.support.mastodon.url - )}
- ${t("SourceCode")}
- ${socialLink( + `${t("SupportSelfTroubleshooting")}

` + + `${t("FollowSupport")}
` + + `${socialLinks(obj.lang)}
` + + `${t("SourceCode")}
` + + `${socialLink( emoji("🐙"), "github", repo.replace("https://github.com/", ''), repo )}
${t("SupportNote")}` diff --git a/src/modules/processing/match.js b/src/modules/processing/match.js index 7b769dc0..af0a1d90 100644 --- a/src/modules/processing/match.js +++ b/src/modules/processing/match.js @@ -20,6 +20,7 @@ import vine from "./services/vine.js"; import pinterest from "./services/pinterest.js"; import streamable from "./services/streamable.js"; import twitch from "./services/twitch.js"; +import rutube from "./services/rutube.js"; export default async function (host, patternMatch, url, lang, obj) { try { @@ -130,6 +131,13 @@ export default async function (host, patternMatch, url, lang, obj) { isAudioOnly: obj.isAudioOnly }); break; + case "rutube": + r = await rutube({ + id: patternMatch["id"], + quality: obj.vQuality, + isAudioOnly: isAudioOnly + }); + break; default: return apiJSON(0, { t: errorUnsupported(lang) }); } diff --git a/src/modules/processing/services/rutube.js b/src/modules/processing/services/rutube.js new file mode 100644 index 00000000..c62191df --- /dev/null +++ b/src/modules/processing/services/rutube.js @@ -0,0 +1,30 @@ +import HLS from 'hls-parser'; +import { maxVideoDuration } from "../../config.js"; + +export default async function(obj) { + let quality = obj.quality === "max" ? "9000" : obj.quality; + let play = await fetch(`https://rutube.ru/api/play/options/${obj.id}/?no_404=true&referer&pver=v2`).then((r) => { return r.json() }).catch(() => { return false }); + if (!play) return { error: 'ErrorCouldntFetch' }; + + if ("hls" in play.live_streams) return { error: 'ErrorLiveVideo' }; + if (!play.video_balancer || play.detail) return { error: 'ErrorEmptyDownload' }; + + if (play.duration > maxVideoDuration) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] }; + + let m3u8 = await fetch(play.video_balancer.m3u8).then((r) => { return r.text() }).catch(() => { return false }); + if (!m3u8) return { error: 'ErrorCouldntFetch' }; + + m3u8 = HLS.parse(m3u8).variants.sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth)); + + let bestQuality = m3u8[0]; + if (Number(quality) < bestQuality.resolution.height) { + bestQuality = m3u8.find((i) => (Number(quality) === i["resolution"].height)); + } + + return { + urls: bestQuality.uri, + isM3U8: true, + audioFilename: `rutube_${play.id}_audio`, + filename: `rutube_${play.id}_${bestQuality.resolution.width}x${bestQuality.resolution.height}.mp4` + } +} diff --git a/src/modules/processing/servicesConfig.json b/src/modules/processing/servicesConfig.json index 47f501fe..c7e62a1e 100644 --- a/src/modules/processing/servicesConfig.json +++ b/src/modules/processing/servicesConfig.json @@ -78,6 +78,12 @@ "tld": "tv", "patterns": [":channel/clip/:clip"], "enabled": true + }, + "rutube": { + "alias": "rutube videos", + "tld": "ru", + "patterns": ["video/:id", "play/embed/:id"], + "enabled": true } } } diff --git a/src/modules/processing/servicesPatternTesters.js b/src/modules/processing/servicesPatternTesters.js index a22b2d58..019aa5da 100644 --- a/src/modules/processing/servicesPatternTesters.js +++ b/src/modules/processing/servicesPatternTesters.js @@ -35,4 +35,6 @@ export const testers = { "streamable": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length === 6), "twitch": (patternMatch) => ((patternMatch["channel"] && patternMatch["clip"] && patternMatch["clip"].length <= 100)), + + "rutube": (patternMatch) => ((patternMatch["id"] && patternMatch["id"].length === 32)), } diff --git a/src/modules/stream/types.js b/src/modules/stream/types.js index 50cd11c6..ffc868bb 100644 --- a/src/modules/stream/types.js +++ b/src/modules/stream/types.js @@ -152,7 +152,7 @@ export function streamVideoOnly(streamInfo, res) { '-c', 'copy' ] if (streamInfo.mute) args.push('-an'); - if (streamInfo.service === "vimeo") args.push('-bsf:a', 'aac_adtstoasc'); + if (streamInfo.service === "vimeo" || streamInfo.service === "rutube") args.push('-bsf:a', 'aac_adtstoasc'); if (format === "mp4") args.push('-movflags', 'faststart+frag_keyframe+empty_moov'); args.push('-f', format, 'pipe:3'); const ffmpegProcess = spawn(ffmpeg, args, { diff --git a/src/modules/sub/utils.js b/src/modules/sub/utils.js index e07079d8..720acb85 100644 --- a/src/modules/sub/utils.js +++ b/src/modules/sub/utils.js @@ -50,7 +50,7 @@ export function metadataManager(obj) { return commands; } export function cleanURL(url, host) { - switch(host) { + switch (host) { case "vk": url = url.includes('clip') ? url.split('&')[0] : url.split('?')[0]; break; diff --git a/src/test/tests.json b/src/test/tests.json index 80b05c95..369852ba 100644 --- a/src/test/tests.json +++ b/src/test/tests.json @@ -1099,5 +1099,60 @@ "code": 200, "status": "stream" } + }], + "rutube": [{ + "name": "regular video", + "url": "https://rutube.ru/video/b2f6c27649907c2fde0af411b03825eb/", + "params": {}, + "expected": { + "code": 200, + "status": "stream" + } + }, { + "name": "vertical video (isAudioMuted)", + "url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/", + "params": { + "isAudioMuted": true + }, + "expected": { + "code": 200, + "status": "stream" + } + }, { + "name": "russian region lock", + "url": "https://rutube.ru/video/b521653b4f71ece57b8ff54e57ca9b82/", + "params": {}, + "expected": { + "code": 200, + "status": "stream" + } + }, { + "name": "vertical video", + "url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/", + "params": {}, + "expected": { + "code": 200, + "status": "stream" + } + }, { + "name": "vertical video (isAudioOnly)", + "url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/", + "params": { + "isAudioOnly": true + }, + "expected": { + "code": 200, + "status": "stream" + } + }, { + "name": "vertical video (isAudioMuted)", + "url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/", + "params": { + "isAudioMuted": true + }, + "expected": { + "code": 200, + "status": "stream" + } }] }