Merge branch 'current' into instagram-stories

This commit is contained in:
dumbmoron
2023-09-16 23:34:23 +02:00
committed by GitHub
32 changed files with 1045 additions and 269 deletions

View File

@@ -1,5 +1,36 @@
{
"current": {
"version": "7.5",
"date": "September 16, 2023",
"title": "support for twitch clips and rutube!",
"banner": {
"file": "twitchupdate.webp",
"width": 851,
"height": 640
},
"content": "hey! this update (finally) adds support for twitch clips and rutube, among other smaller changes.\n\nservice improvements:\n*; added support for twitch clips. no vods, they're unnecessary. just clip whatever you want to download!\n*; added support for rutube in case you ever wanted to download something russian.\n\ninterface improvements:\n*; added a note about cobalt not being affiliated with any supported services.\n*; added a note about meta (the company) in russian.\n*; better russian localization. will keep improving it to make it sound not so robotic over time.\n\nother improvements:\n*; all official servers are now using the docker package. and so should you!\n*; moved the load balancer to poland. requests should be slightly faster now.\n*; minor codebase clean up.\n\nif you're confused about the new domain, read the older changelog! just scroll lower and press \"expand\".\n\ni hope you find this update useful and have a wonderful day :)\n\nbtw, cobalt has a pretty active community server on discord. go to about > support & source code to join!"
},
"history": [{
"version": "7.4",
"date": "September 9, 2023",
"title": "new domain, what's coming in future, bug fixes, and more!",
"banner": {
"file": "newdomain.webp",
"width": 960,
"height": 540
},
"content": "cobalt is finally moving to its own domain! many of you have been anticipating this, and many kept forgetting the link due to how cryptic it was.\n\nwell, worry no more - <span class=\"text-backdrop\">cobalt.tools</span> is here.\n\nif you haven't yet, open <a class=\"text-backdrop link\" href=\"https://co.wukko.me\" target=\"_blank\">co.wukko.me</a> to transfer your settings here! no additional action from you is required. just open the old link and cobalt will do everything for you :)\n\nmake sure to <span class=\"text-backdrop\">update your bookmarks</span> and reinstall the web app!\n\nhere's what domain change means:\n*; still no ads, same owner, same features, same reliability. just a way more rememberable link (it's literally two words).\n*; cobalt.tools makes it clear that cobalt is a tool and that it's \"cobalt\", not \"wukko\".\n*; i can host various versions of cobalt on subdomains without links looking awkward.\n*; i can host cobalt-related websites without polluting my personal domain's dns (such as crowdin).\n*; i stand by same privacy policies (and in fact am using the same exact server as before).\n\nthe domain change is required for the future of cobalt.\n\nhere's what's coming soon:\n*; support for many top-requested sites, such as (but not limited to) twitch and niconico.\n*; education version of cobalt, as often requested by students and educators.\n*; major localization system upgrade, allowing for simpler community contributions.\n*; region-specific versions with 100% translations and tweaks.\n*; native clients for desktop and mobile (not sure about this one, i'm no superman).\n*; ...and more!\n\nnow, here's what's new in 7.4:\n*; tabs in popups now scroll to top on tab bar tap.\n*; padding across web app was tuned.\n*; (obviously) a migration agent. soon will be used for importing and exporting settings.\n*; some minor clean ups in codebase.\n\nif you want to help cobalt achieve goals listed above, consider donating! donations are the only way i can keep cobalt ad-less, powerful, (basically) limitless, and also 100% free.\n\nin fact, donations have helped me grow cobalt more than i've ever anticipated. just imagine how much better it will be in a year.\n\ngo to donations down below to find ways to donate!\n\nthank you for reading through all of this. i hope you enjoy this update and have a great day :D"
}, {
"version": "7.2 & 7.3",
"date": "September 6, 2023",
"title": "extended video length limit, metadata toggle, ui improvements, and more!",
"banner": {
"file": "meowthsnap.webp",
"width": 500,
"height": 280
},
"content": "this update gives cobalt a sharp look in chromium browsers and makes it even more useful than before. check out the full changelog below!\n\nservice improvements:\n*; increased video length limit from 3 hours to 5 hours. feel free to download lectures you need :)\n*; you can now disable file metadata in settings.\n*; fixed a bug which previously caused some downloads to end up being 0 bytes.\n\nui improvements:\n*; fixed clickable area for urgent notice (text on top).\n*; fixed blurry header in chrome.\n*; fixed blurry tab bar in chrome.\n*; fixed blurry switches in chrome.\n*; fixed weirdly rounded corners in popups.\n*; fixed 1px gap on edges of various elements in popup in chrome.\n*; fixed overscrolling in other settings tab on ios.\n*; fixed unexpected button highlight effect on phones.\n*; removed outdated fixes for tiny screens.\n\nother improvements:\n*; cobalt web & api start faster than before, additional preparation functions aren't unexpectedly run anymore.\n*; cobalt is now available as a docker package. check it out on <a class=\"text-backdrop link\" href=\"https://github.com/wukko/cobalt/pkgs/container/cobalt\" target=\"_blank\">github</a>.\n\nthank you for being here. i hope you have a great day :D"
}, {
"version": "7.1",
"date": "August 20, 2023",
"title": "instagram, streamable, video metadata, and more!",
@@ -9,8 +40,7 @@
"height": 358
},
"content": "service improvements:\n*; extended instagram support: high quality photos, videos, reels. everything should work without any issues, enjoy! :)\n*; added support for streamable.com (thanks to <a class=\"text-backdrop link\" href=\"https://github.com/wukko/cobalt/pull/179\" target=\"_blank\">#179</a>)\n*; added video metadata to youtube videos.\n*; fixed vk video downloads.\n*; vxtwitter links are now supported.\n*; fixed support for youtube audio dubs.\n\nui improvements:\n*; fixed picker popup: it's now scrollable in all cases and clickable areas don't overlap each other.\n\nbackend improvements:\n*; cobalt will now let you know if something goes wrong during video download instead of nuking the stream.\n*; added support for cookies (thanks to <a class=\"text-backdrop link\" href=\"https://github.com/wukko/cobalt/pull/177\" target=\"_blank\">#177</a>)\n*; replaced got with undici (thanks to <a class=\"text-backdrop link\" href=\"https://github.com/wukko/cobalt/pull/182\" target=\"_blank\">#182</a>). downloads should be slightly faster and clean of garbage in headers.\n\ninternal improvements:\n*; moved host overrides into its own module.\n*; minor clean ups.\n\neven more cool stuff is coming in future updates! thank you for using cobalt :D"
},
"history": [{
}, {
"version": "7.0",
"date": "August 15, 2023",
"title": "biggest ui refresh yet!",

View File

@@ -33,7 +33,9 @@ const names = {
"🔗": "link",
"⌨": "keyboard",
"📑": "boring_document",
"🧮": "abacus"
"🧮": "abacus",
"😸": "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">
@@ -68,17 +68,19 @@ export function popup(obj) {
}
return `
${obj.standalone ? `<div id="popup-${obj.name}" class="popup center${!obj.buttonOnly ? " box": ''}${classes.length > 0 ? ' ' + classes.join(' ') : ''}">` : ''}
<div id="popup-header" class="popup-header${!obj.buttonOnly ? " glass-bkg": ''}">
<div id="popup-header" class="popup-header">
<div id="popup-header-contents">
${obj.buttonOnly ? obj.header.emoji : ``}
${obj.header.aboveTitle ? `<a id="popup-above-title" target="_blank" href="${obj.header.aboveTitle.url}">${obj.header.aboveTitle.text}</a>` : ''}
${obj.header.title ? `<div id="popup-title">${obj.header.title}</div>` : ''}
${obj.header.subtitle ? `<div id="popup-subtitle">${obj.header.subtitle}</div>` : ''}
</div>
${!obj.buttonOnly ? `<div class="glass-bkg alone"></div>`: ''}
</div>
<div id="popup-content" class="popup-content-inner">
${body}${obj.buttonOnly ? `<button id="close-error" class="switch" onclick="popup('${obj.name}', 0)">${obj.buttonText}</button>` : ''}
</div>
${classes.includes("small") ? `<div class="glass-bkg small"></div>`: ''}
${obj.standalone ? `</div>` : ''}`
}
@@ -97,14 +99,18 @@ export function multiPagePopup(obj) {
return `
<div id="popup-${obj.name}" class="popup center box scrollable">
<div id="popup-content">
${obj.header ? `<div id="popup-header" class="popup-header glass-bkg">
${obj.header ? `<div id="popup-header" class="popup-header">
<div id="popup-header-contents">
${obj.header.aboveTitle ? `<a id="popup-above-title" target="_blank" href="${obj.header.aboveTitle.url}">${obj.header.aboveTitle.text}</a>` : ''}
${obj.header.title ? `<div id="popup-title">${obj.header.title}</div>` : ''}
${obj.header.subtitle ? `<div id="popup-subtitle">${obj.header.subtitle}</div>` : ''}
</div>
<div class="glass-bkg alone"></div>
</div>` : ''}${tabContent}</div>
<div id="popup-tabs" class="switches popup-tabs glass-bkg"><div class="switches popup-tabs-child">${tabs}</div></div>
<div id="popup-tabs" class="switches popup-tabs">
<div class="switches popup-tabs-child">${tabs}</div>
<div class="glass-bkg alone"></div>
</div>
</div>`
}
export function collapsibleList(arr) {
@@ -136,20 +142,34 @@ export function popupWithBottomButtons(obj) {
return `
<div id="popup-${obj.name}" class="popup center box scrollable">
<div id="popup-content">
${obj.header ? `<div id="popup-header" class="popup-header glass-bkg">
${obj.header ? `<div id="popup-header" class="popup-header">
<div id="popup-header-contents">
${obj.header.aboveTitle ? `<a id="popup-above-title" target="_blank" href="${obj.header.aboveTitle.url}">${obj.header.aboveTitle.text}</a>` : ''}
${obj.header.title ? `<div id="popup-title">${obj.header.title}</div>` : ''}
${obj.header.subtitle ? `<div id="popup-subtitle">${obj.header.subtitle}</div>` : ''}
${obj.header.explanation ? `<div class="explanation">${obj.header.explanation}</div>` : ''}
</div>
<div class="glass-bkg alone"></div>
</div>` : ''}${obj.content}</div>
<div id="popup-tabs" class="switches popup-tabs glass-bkg"><div id="picker-buttons" class="switches popup-tabs-child">${tabs}</div></div>
<div id="popup-tabs" class="switches popup-tabs">
<div id="picker-buttons" class="switches popup-tabs-child">${tabs}</div>
<div class="glass-bkg alone"></div>
</div>
</div>`
}
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>
@@ -205,7 +225,9 @@ export function celebrationsEmoji() {
}
export function urgentNotice(obj) {
if (obj.visible) {
return `<div id="urgent-notice" class="urgent-notice explanation" onclick="${obj.action}">${emoji(obj.emoji, 18)} ${obj.text}</div>`
return `<div id="urgent-notice" class="urgent-notice explanation">` +
`<span class="urgent-text" onclick="${obj.action}">${emoji(obj.emoji, 18)} ${obj.text}</span>` +
`</div>`
}
return ``
}
@@ -226,3 +248,10 @@ export function keyboardShortcuts(arr) {
return base;
}
export function webLoc(t, arr) {
let base = ``;
for (let i = 0; i < arr.length; i++) {
base += `${arr[i]}:` + "`" + t(arr[i]) + "`" + `,`
}
return `{${base}};`
}

View File

@@ -1,4 +1,4 @@
import { checkbox, collapsibleList, explanation, footerButtons, multiPagePopup, popup, popupWithBottomButtons, sep, settingsCategory, switcher, socialLink, urgentNotice, keyboardShortcuts } 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";
@@ -33,7 +33,7 @@ export default function(obj) {
let isIOS = ua.match("iphone os");
let isMobile = ua.match("android") || ua.match("iphone os");
let platform = isMobile ? "m" : "p";
let platform = isMobile ? "m" : "d";
if (isMobile && isIOS) platform = "i";
audioFormats[0]["text"] = t('SettingsAudioFormatBest');
@@ -71,11 +71,11 @@ 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>
<body id="cobalt-body" ${platform === "p" ? 'class="desktop"' : ''} data-nosnippet ontouchstart>
<body id="cobalt-body" ${platform === "d" ? 'class="desktop"' : ''} data-nosnippet ontouchstart>
<body id="notification-area"></div>
${multiPagePopup({
name: "about",
@@ -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")}`
@@ -440,16 +436,19 @@ export default function(obj) {
name: "miscellaneous",
title: t('Miscellaneous'),
body: checkbox([{
action: "disableChangelog",
name: t("SettingsDisableNotifications")
}, {
action: "downloadPopup",
name: t("SettingsEnableDownloadPopup"),
padding: "no-margin",
aria: t("AccessibilityEnableDownloadPopup")
}, {
action: "disableMetadata",
name: t("SettingsDisableMetadata")
}, {
action: "disableChangelog",
name: t("SettingsDisableNotifications"),
padding: "no-margin"
}])
})
}],
}]
})}
${popupWithBottomButtons({
name: "picker",
@@ -466,7 +465,7 @@ export default function(obj) {
name: "download",
standalone: true,
buttonOnly: true,
classes: ["small", "glass-bkg"],
classes: ["small"],
header: {
closeAria: t('AccessibilityGoBack'),
emoji: emoji("🐱", 78, 1, 1),
@@ -487,20 +486,34 @@ export default function(obj) {
name: "error",
standalone: true,
buttonOnly: true,
classes: ["small", "glass-bkg"],
classes: ["small"],
header: {
closeAria: t('AccessibilityGoBack'),
title: t('TitlePopupError'),
emoji: emoji("😿", 78, 1, 1),
},
body: `<div id="desc-error" class="desc-padding subtext"></div>`,
body: `<div id="desc-error" class="desc-padding subtext desc-error"></div>`,
buttonText: t('ErrorPopupCloseButton')
})}
</div>
<div id="popup-migration-container" class="popup-from-bottom">
${popup({
name: "migration",
standalone: true,
buttonOnly: true,
classes: ["small"],
header: {
title: t('NewDomainWelcomeTitle'),
emoji: emoji("😸", 78, 1, 1),
},
body: `<div id="desc-migration" class="desc-padding subtext desc-error">${t('NewDomainWelcome')}</div>`,
buttonText: t('ErrorPopupCloseButton')
})}
<div id="popup-backdrop-message" onclick="popup('message', 0)"></div>
</div>
<div id="popup-backdrop" onclick="hideAllPopups()"></div>
<div id="home" style="visibility:hidden">
${urgentNotice({
emoji: "🔗",
emoji: "👾",
text: t("UrgentFeatureUpdate71"),
visible: true,
action: "popup('about', 1, 'changelog')"
@@ -551,20 +564,25 @@ export default function(obj) {
</div>
</body>
<script type="text/javascript">
const loc = {
noInternet: ` + "`" + t('ErrorNoInternet') + "`" + `,
noURLReturned: ` + "`" + t('ErrorNoUrlReturned') + "`" + `,
unknownStatus: ` + "`" + t('ErrorUnknownStatus') + "`" + `,
collapseHistory: ` + "`" + t('ChangelogPressToHide') + "`" + `,
pickerDefault: ` + "`" + t('MediaPickerTitle') + "`" + `,
pickerImages: ` + "`" + t('ImagePickerTitle') + "`" + `,
pickerImagesExpl: ` + "`" + t(`ImagePickerExplanation${isMobile ? "Phone" : "PC"}`) + "`" + `,
pickerDefaultExpl: ` + "`" + t(`MediaPickerExplanation${isMobile ? "Phone" : "PC"}`) + "`" + `,
featureErrorGeneric: ` + "`" + t('FeatureErrorGeneric') + "`" + `,
clipboardErrorNoPermission: ` + "`" + t('ClipboardErrorNoPermission') + "`" + `,
clipboardErrorFirefox: ` + "`" + t('ClipboardErrorFirefox') + "`" + `,
};
let apiURL = '${process.env.apiURL ? process.env.apiURL.slice(0, -1) : ''}';
const loc = ${webLoc(t,
[
'ErrorNoInternet',
'ErrorNoUrlReturned',
'ErrorUnknownStatus',
'ChangelogPressToHide',
'MediaPickerTitle',
'MediaPickerExplanationPhone',
'MediaPickerExplanationPC',
'ImagePickerTitle',
'ImagePickerExplanationPhone',
'ImagePickerExplanationPC',
'FeatureErrorGeneric',
'ClipboardErrorNoPermission',
'ClipboardErrorFirefox',
'DataTransferSuccess',
'DataTransferError'
])}
</script>
<script type="text/javascript" src="cobalt.js"></script>
</html>

View File

@@ -8,6 +8,9 @@ export default function (inHost, inURL) {
url = url.split("?")[0].replace("www.", "");
url = `https://youtube.com/watch?v=${url.replace("https://youtube.com/live/", "")}`
}
if (url.includes('youtube.com/shorts/')) {
url = url.split('?')[0].replace('shorts/', 'watch?v=');
}
break;
case "youtu":
if (url.startsWith("https://youtu.be/")) {
@@ -32,6 +35,11 @@ export default function (inHost, inURL) {
url = url.replace(url.split('/')[5], '')
}
break;
case "twitch":
if (url.includes('clips.twitch.tv')) {
url = url.split('?')[0].replace('clips.twitch.tv/', 'twitch.tv/_/clip/');
}
break;
}
return {
host: host,

View File

@@ -19,10 +19,12 @@ import instagram from "./services/instagram.js";
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 {
let r, isAudioOnly = !!obj.isAudioOnly;
let r, isAudioOnly = !!obj.isAudioOnly, disableMetadata = !!obj.disableMetadata;
if (!testers[host]) return apiJSON(0, { t: errorUnsupported(lang) });
if (!(testers[host](patternMatch))) return apiJSON(0, { t: brokenLink(lang, host) });
@@ -125,6 +127,20 @@ export default async function (host, patternMatch, url, lang, obj) {
isAudioOnly: isAudioOnly,
});
break;
case "twitch":
r = await twitch({
clipId: patternMatch["clip"] ? patternMatch["clip"] : false,
quality: obj.vQuality,
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) });
}
@@ -134,7 +150,7 @@ export default async function (host, patternMatch, url, lang, obj) {
if (r.error) return apiJSON(0, { t: Array.isArray(r.error) ? loc(lang, r.error[0], r.error[1]) : loc(lang, r.error) });
return matchActionDecider(r, host, obj.aFormat, isAudioOnly, lang, isAudioMuted);
return matchActionDecider(r, host, obj.aFormat, isAudioOnly, lang, isAudioMuted, disableMetadata);
} catch (e) {
return apiJSON(0, { t: genericError(lang, host) })
}

View File

@@ -2,17 +2,17 @@ import { audioIgnore, services, supportedAudio } from "../config.js";
import { apiJSON } from "../sub/utils.js";
import loc from "../../localization/manager.js";
export default function(r, host, audioFormat, isAudioOnly, lang, isAudioMuted) {
export default function(r, host, audioFormat, isAudioOnly, lang, isAudioMuted, disableMetadata) {
let action,
responseType = 2,
defaultParams = {
u: r.urls,
service: host,
filename: r.filename,
fileMetadata: r.fileMetadata ? r.fileMetadata : false
fileMetadata: !disableMetadata ? r.fileMetadata : false
},
params = {}
if (r.isPhoto) action = "photo";
else if (r.picker) action = "picker"
else if (isAudioMuted) action = "muteVideo";
@@ -55,7 +55,7 @@ export default function(r, host, audioFormat, isAudioOnly, lang, isAudioMuted) {
case "tiktok":
params = { type: "bridge" };
break;
case "vine":
case "instagram":
case "tumblr":

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

@@ -0,0 +1,76 @@
import { maxVideoDuration } from "../../config.js";
const gqlURL = "https://gql.twitch.tv/gql";
const clientIdHead = { "client-id": "kimne78kx3ncx6brgo4mv6wki5h1ko" };
export default async function (obj) {
let req_metadata = await fetch(gqlURL, {
method: "POST",
headers: clientIdHead,
body: JSON.stringify({
query: `{
clip(slug: "${obj.clipId}") {
broadcaster {
login
}
createdAt
curator {
login
}
durationSeconds
id
medium: thumbnailURL(width: 480, height: 272)
title
videoQualities {
quality
sourceURL
}
}
}`
})
}).then((r) => { return r.status === 200 ? r.json() : false; }).catch(() => { return false });
if (!req_metadata) return { error: 'ErrorCouldntFetch' };
let clipMetadata = req_metadata.data.clip;
if (clipMetadata.durationSeconds > maxVideoDuration / 1000) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
if (!clipMetadata.videoQualities || !clipMetadata.broadcaster) return { error: 'ErrorEmptyDownload' };
let req_token = await fetch(gqlURL, {
method: "POST",
headers: clientIdHead,
body: JSON.stringify([
{
"operationName": "VideoAccessToken_Clip",
"variables": {
"slug": obj.clipId
},
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11"
}
}
}
])
}).then((r) => { return r.status === 200 ? r.json() : false; }).catch(() => { return false });
if (!req_token) return { error: 'ErrorCouldntFetch' };
let formats = clipMetadata.videoQualities;
let format = formats.find(f => f.quality === obj.quality) || formats[0];
return {
type: "bridge",
urls: `${format.sourceURL}?${new URLSearchParams({
sig: req_token[0].data.clip.playbackAccessToken.signature,
token: req_token[0].data.clip.playbackAccessToken.value
})}`,
fileMetadata: {
title: clipMetadata.title,
artist: `Twitch Clip by @${clipMetadata.broadcaster.login}, clipped by @${clipMetadata.curator.login}`,
},
filename: `twitchclip_${clipMetadata.id}_${format.quality}p.mp4`,
audioFilename: `twitchclip_${clipMetadata.id}_audio`
}
}

View File

@@ -22,7 +22,7 @@
"enabled": true
},
"youtube": {
"alias": "youtube videos & shorts & music",
"alias": "youtube videos, shorts & music",
"patterns": ["watch?v=:id", "embed/:id"],
"bestAudio": "opus",
"enabled": true
@@ -32,7 +32,7 @@
"enabled": true
},
"tiktok": {
"alias": "tiktok videos & photos & audio",
"alias": "tiktok videos, photos & audio",
"patterns": [":user/video/:postId", ":id", "t/:id"],
"audioFormats": ["best", "m4a", "mp3"],
"enabled": true
@@ -75,6 +75,18 @@
"alias": "streamable.com",
"patterns": [":id", "o/:id", "e/:id", "s/:id"],
"enabled": true
},
"twitch": {
"alias": "twitch clips",
"tld": "tv",
"patterns": [":channel/clip/:clip"],
"enabled": true
},
"rutube": {
"alias": "rutube videos",
"tld": "ru",
"patterns": ["video/:id", "play/embed/:id"],
"enabled": true
}
}
}
}

View File

@@ -22,16 +22,20 @@ export const testers = {
|| (patternMatch["id"] && patternMatch["id"].length < 21 && patternMatch["user"] && patternMatch["user"].length <= 32)),
"vimeo": (patternMatch) => ((patternMatch["id"] && patternMatch["id"].length <= 11)),
"soundcloud": (patternMatch) => (patternMatch["author"]?.length <= 25 && patternMatch["song"]?.length <= 255)
|| (patternMatch["shortLink"] && patternMatch["shortLink"].length <= 32),
"instagram": (patternMatch) => (patternMatch.postId?.length <= 12)
|| (patternMatch.username?.length <= 30 && patternMatch.storyId?.length <= 24),
"vine": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length <= 12),
"pinterest": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length <= 128),
"streamable": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length === 6)
"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

@@ -70,7 +70,7 @@ function setup() {
})
break;
case 'web':
console.log(Bright("\nAwesome! What's the domain this web app instance will be running on? (localhost)\nExample: co.wukko.me"));
console.log(Bright("\nAwesome! What's the domain this web app instance will be running on? (localhost)\nExample: cobalt.tools"));
rl.question(q, webURL => {
ob['webURL'] = `http://localhost:9001/`;

View File

@@ -16,7 +16,8 @@ export async function streamDefault(streamInfo, res) {
res.setHeader('Content-disposition', `attachment; filename="${streamInfo.isAudioOnly ? `${streamInfo.filename}.${streamInfo.audioFormat}` : regFilename}"`);
const { body: stream, headers } = await request(streamInfo.urls, {
headers: { 'user-agent': genericUserAgent }
headers: { 'user-agent': genericUserAgent },
maxRedirections: 16
});
res.setHeader('content-type', headers['content-type']);
@@ -33,7 +34,9 @@ export async function streamLiveRender(streamInfo, res) {
try {
if (streamInfo.urls.length !== 2) return fail(res);
let { body: audio } = await request(streamInfo.urls[1]);
let { body: audio } = await request(streamInfo.urls[1], {
maxRedirections: 16
});
let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1],
args = [
@@ -149,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

@@ -6,7 +6,7 @@ const apiVar = {
vQuality: ["max", "4320", "2160", "1440", "1080", "720", "480", "360", "240", "144"],
aFormat: ["best", "mp3", "ogg", "wav", "opus"]
},
booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio", "isAudioMuted", "dubLang", "vimeoDash"]
booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio", "isAudioMuted", "dubLang", "vimeoDash", "disableMetadata"]
}
const forbiddenChars = ['}', '{', '(', ')', '\\', '%', '>', '<', '^', '*', '!', '~', ';', ':', ',', '`', '[', ']', '#', '$', '"', "'", "@", '=='];
const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '='];
@@ -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;
@@ -70,9 +70,6 @@ export function cleanURL(url, host) {
url = url.replaceAll(forbiddenChars[i], '')
}
url = url.replace('https//', 'https://')
if (url.includes('youtube.com/shorts/')) {
url = url.split('?')[0].replace('shorts/', 'watch?v=');
}
return url.slice(0, 128)
}
export function cleanString(string) {
@@ -101,13 +98,14 @@ export function checkJSONPost(obj) {
isNoTTWatermark: false,
isTTFullAudio: false,
isAudioMuted: false,
disableMetadata: false,
dubLang: false,
vimeoDash: false
}
try {
let objKeys = Object.keys(obj);
if (!(objKeys.length <= 9 && obj.url)) return false;
let defKeys = Object.keys(def);
if (objKeys.length > defKeys.length + 1 || !obj.url) return false;
for (let i in objKeys) {
if (String(objKeys[i]) !== "url" && defKeys.includes(objKeys[i])) {