support for rutube, fixes, accommodations for multi lang

This commit is contained in:
wukko 2023-09-16 23:38:07 +06:00
parent 05bb7bcd07
commit e721cf9878
16 changed files with 164 additions and 38 deletions

View file

@ -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",

View file

@ -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": {

View file

@ -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);
}

View file

@ -0,0 +1,5 @@
<svg width="100%" height="100%" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 7C4 5.89543 4.89543 5 6 5H29C30.1046 5 31 5.89543 31 7V29C31 30.1046 30.1046 31 29 31H4C2.34315 31 1 29.6569 1 28V10C1 8.34314 2.34315 7 4 7Z" fill="#B4ACBC" />
<path d="M28 10C28 8.89543 27.1046 8 26 8H4C2.89543 8 2 8.89543 2 10V28C2 29.1046 2.89543 30 4 30H29.5C28.6716 30 28 29.3284 28 28.5V10Z" fill="#F3EEF8" />
<path d="M4 11C4 10.4477 4.44772 10 5 10H25C25.5523 10 26 10.4477 26 11C26 11.5523 25.5523 12 25 12H5C4.44772 12 4 11.5523 4 11ZM4 14.5C4 14.2239 4.22386 14 4.5 14H25.5C25.7761 14 26 14.2239 26 14.5C26 14.7761 25.7761 15 25.5 15H4.5C4.22386 15 4 14.7761 4 14.5ZM19.5 17C19.2239 17 19 17.2239 19 17.5C19 17.7761 19.2239 18 19.5 18H25.5C25.7761 18 26 17.7761 26 17.5C26 17.2239 25.7761 17 25.5 17H19.5ZM19 20.5C19 20.2239 19.2239 20 19.5 20H25.5C25.7761 20 26 20.2239 26 20.5C26 20.7761 25.7761 21 25.5 21H19.5C19.2239 21 19 20.7761 19 20.5ZM19.5 23C19.2239 23 19 23.2239 19 23.5C19 23.7761 19.2239 24 19.5 24H25.5C25.7761 24 26 23.7761 26 23.5C26 23.2239 25.7761 23 25.5 23H19.5ZM19 26.5C19 26.2239 19.2239 26 19.5 26H25.5C25.7761 26 26 26.2239 26 26.5C26 26.7761 25.7761 27 25.5 27H19.5C19.2239 27 19 26.7761 19 26.5ZM6 17C4.89543 17 4 17.8954 4 19V25C4 26.1046 4.89543 27 6 27H15C16.1046 27 17 26.1046 17 25V19C17 17.8954 16.1046 17 15 17H6Z" fill="#998EA4" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -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\n<span class=\"text-backdrop\">cobalt.tools</span> 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 <span class=\"text-backdrop\">not affiliated</span> with any services listed above."
}
}

View file

@ -102,8 +102,8 @@
"CollapseServices": "что поддерживается?",
"CollapseSupport": "поддержка и исходный код",
"CollapsePrivacy": "политика конфиденциальности",
"ServicesNote": "этот список далеко не финальный и постоянно пополняется. заглядывай сюда почаще, тогда точно будешь знать, что поддерживается!",
"FollowSupport": "оставайтесь на связи с кобальтом для новостей, поддержки, участия в опросах, и многого другого:",
"ServicesNote": "этот список далеко не финальный и постоянно пополняется, заглядывай сюда почаще!",
"FollowSupport": "подписывайся на соц.сети кобальта для новостей, поддержки, участия в опросах, и многого другого:",
"SupportNote": "так как я занимаюсь разработкой и поддержкой в одиночку, время ожидания ответа может достигать нескольких часов. но я отвечаю всем, так что не стесняйся.",
"SourceCode": "пиши о проблемах, шарься в исходнике, или же форкай репозиторий:",
"PrivacyPolicy": "политика конфиденциальности кобальта довольно проста: никакие данные о тебе никогда не собираются и не хранятся. нуль, ноль, нада, ничего.\nто, что ты скачиваешь, - твоё личное дело, а не чьё-либо ещё.\n\nесли твоей загрузке требуется лайв рендер, то некоторые неотслеживаемые данные временно держатся в ОЗУ сервера. это необходимо для работы данной функции.\n\nв этом случае данные о запрошенном контенте хранятся в течение <span class=\"text-backdrop\">20 секунд</span>. по истечении этого времени всё стирается. ни у кого (даже у меня) нет доступа к временно хранящимся данным, так как официальная кодовая база кобальта не предусматривает возможности их чтения вне функций обработки.\n\nты всегда можешь посмотреть <a class=\"text-backdrop link\" href=\"{repo}\" target=\"_blank\">исходный код кобальта</a> и убедиться, что всё так, как заявлено.",
@ -144,6 +144,8 @@
"NewDomainWelcomeTitle": "привет!",
"NewDomainWelcome": "кобальт переезжает! те же функции, тот же владелец, просто более запоминающийся домен. по-прежнему без рекламы.\n\n<span class=\"text-backdrop\">cobalt.tools</span> - новый основной домен, т.е. где ты сейчас находишься. не забудь обновить закладки и переустановить веб-приложение!",
"DataTransferSuccess": "кстати, твои настройки были перенесены автоматически :)",
"DataTransferError": "при переносе настроек что-то пошло не так. придётся зайти в настройки и настроить кобальт вручную."
"DataTransferError": "при переносе настроек что-то пошло не так. придётся зайти в настройки и настроить кобальт вручную.",
"SupportNotAffiliated": "кобальт <span class=\"text-backdrop\">не аффилирован</span> ни с одним из перечисленных выше сервисов.",
"SupportMetaNoticeRU": "деятельность meta platforms inc. (владелец instagram) запрещена на территории россии."
}
}

View file

@ -34,7 +34,8 @@ const names = {
"⌨": "keyboard",
"📑": "boring_document",
"🧮": "abacus",
"😸": "cat_grin"
"😸": "cat_grin",
"📰": "newspaper"
}
let sizing = {
18: 0.8,

View file

@ -1,4 +1,4 @@
import { celebrations } from "../config.js";
import { authorInfo, celebrations } from "../config.js";
import emoji from "../emoji.js";
export const backButtonSVG = `<svg width="22" height="22" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
@ -160,6 +160,16 @@ export function popupWithBottomButtons(obj) {
export function socialLink(emji, name, handle, url) {
return `<div class="cobalt-support-link">${emji} ${name}: <a class="text-backdrop link" href="${url}" target="_blank">${handle}</a></div>`
}
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 `<div id="settings-${obj.name}" class="settings-category">
<div class="category-title">${obj.title ? obj.title : obj.name}</div>

View file

@ -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) {
<link rel="stylesheet" href="fonts/notosansmono.css" rel="preload" />
<link rel="stylesheet" href="cobalt.css" />
<link rel="me" href="${authorInfo.support.mastodon.url}">
<link rel="me" href="${authorInfo.support.default.mastodon.url}">
<noscript><div style="margin: 2rem;">${t('NoScriptMessage')}</div></noscript>
</head>
@ -99,7 +99,11 @@ export default function(obj) {
text: collapsibleList([{
name: "services",
title: `${emoji("🔗")} ${t("CollapseServices")}`,
body: `${enabledServices}<br/><br/>${t("ServicesNote")}`
body: `${enabledServices}`
+ `<div class="explanation embedded">${t("SupportNotAffiliated")}`
+ `${obj.lang === "ru" ? `<br>${t("SupportMetaNoticeRU")}` : ''}`
+ `</div>`
+ `${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")}<br/><br/>
${t("FollowSupport")}<br/>
${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
)}<br/>
${t("SourceCode")}<br/>
${socialLink(
`${t("SupportSelfTroubleshooting")}<br/><br/>`
+ `${t("FollowSupport")}<br/>`
+ `${socialLinks(obj.lang)}<br/>`
+ `${t("SourceCode")}<br/>`
+ `${socialLink(
emoji("🐙"), "github", repo.replace("https://github.com/", ''), repo
)}<br/>
${t("SupportNote")}`

View file

@ -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) });
}

View file

@ -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`
}
}

View file

@ -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
}
}
}

View file

@ -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)),
}

View file

@ -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, {

View file

@ -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;

View file

@ -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"
}
}]
}