diff --git a/README.md b/README.md index 0df2f90d..30363a36 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ It preserves original media quality so you get best downloads possible (unless y | -------- | :---: | :---: | :----- | | Twitter | ✅ | ✅ | Ability to save multiple videos/GIFs from a single tweet. | | Twitter Spaces | ❌️ | ✅ | Audio metadata. | -| YouTube & Shorts | ✅ | ✅ | Support for 4K, HDR and high FPS videos. | +| YouTube & Shorts | ✅ | ✅ | Support for 8K, 4K, HDR, and high FPS videos. | | YouTube Music | ❌ | ✅ | Audio metadata. | | Reddit | ✅ | ✅ | | | TikTok & douyin | ✅ | ✅ | Video downloads with or without watermark; image slideshow downloads without watermarks. | diff --git a/package.json b/package.json index 93dc9c33..e0428792 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cobalt", "description": "save what you love", - "version": "4.1.1", + "version": "4.2", "author": "wukko", "exports": "./src/cobalt.js", "type": "module", diff --git a/src/cobalt.js b/src/cobalt.js index 9de12f0b..eaa2b9e4 100644 --- a/src/cobalt.js +++ b/src/cobalt.js @@ -64,9 +64,9 @@ if (fs.existsSync('./.env') && process.env.selfURL && process.env.streamSalt && if (req.query.url && req.query.url.length < 150) { let j = await getJSON(req.query.url.trim(), languageCode(req), { ip: req.header('x-forwarded-for') ? req.header('x-forwarded-for') : req.ip, - format: req.query.format ? req.query.format.slice(0, 5) : "webm", - quality: req.query.quality ? req.query.quality.slice(0, 3) : "max", - audioFormat: req.query.audioFormat ? req.query.audioFormat.slice(0, 4) : false, + format: req.query.format ? req.query.format.slice(0, 5) : "mp4", + quality: req.query.quality ? req.query.quality.slice(0, 3) : "mid", + audioFormat: req.query.audioFormat ? req.query.audioFormat.slice(0, 4) : "mp3", isAudioOnly: !!req.query.audio, noWatermark: !!req.query.nw, fullAudio: !!req.query.ttfull, diff --git a/src/config.json b/src/config.json index 044ac2ef..6697cd76 100644 --- a/src/config.json +++ b/src/config.json @@ -25,7 +25,7 @@ } }, "quality": { - "hig": "1080", + "hig": "1440", "mid": "720", "low": "480" }, diff --git a/src/front/cobalt.js b/src/front/cobalt.js index 65b0f1bc..d2cd9fcf 100644 --- a/src/front/cobalt.js +++ b/src/front/cobalt.js @@ -1,18 +1,19 @@ -let isIOS = navigator.userAgent.toLowerCase().match("iphone os"); +let ua = navigator.userAgent.toLowerCase(); +let isIOS = ua.match("iphone os"); +let isMobile = ua.match("android") || ua.match("iphone os"); let version = 14; let regex = new RegExp(/https:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/); let notification = `
` let switchers = { "theme": ["auto", "light", "dark"], - "ytFormat": ["webm", "mp4"], - "quality": ["max", "hig", "mid", "low"], - "defaultAudioFormat": ["mp3", "best", "ogg", "wav", "opus"] + "vFormat": ["mp4", "webm"], + "vQuality": ["hig", "max", "mid", "low"], + "aFormat": ["mp3", "best", "ogg", "wav", "opus"] } let checkboxes = ["disableTikTokWatermark", "fullTikTokAudio"]; -let exceptions = { // used solely for ios devices - "ytFormat": "mp4", - "defaultAudioFormat": "mp3" +let exceptions = { // used for mobile devices + "vQuality": "mid" } function eid(id) { @@ -208,6 +209,9 @@ function popup(type, action, text) { eid("popup-backdrop").style.visibility = vis(action); eid(`popup-${type}`).style.visibility = vis(action); } +function updateMP4Text() { + eid("vFormat-mp4").innerHTML = sGet("vQuality") === "mid" ? "mp4 (h264/av1)" : "mp4 (av1)"; +} function changeSwitcher(li, b) { if (b) { sSet(li, b); @@ -215,9 +219,10 @@ function changeSwitcher(li, b) { (switchers[li][i] === b) ? enable(`${li}-${b}`) : disable(`${li}-${switchers[li][i]}`) } if (li === "theme") detectColorScheme(); + if (li === "vQuality") updateMP4Text(); } else { let pref = switchers[li][0]; - if (isIOS && exceptions[li]) pref = exceptions[li]; + if (isMobile && exceptions[li]) pref = exceptions[li]; sSet(li, pref); for (let i in switchers[li]) { (switchers[li][i] === pref) ? enable(`${li}-${pref}`) : disable(`${li}-${switchers[li][i]}`) @@ -299,6 +304,10 @@ function changeButton(type, text) { break; } } +function resetSettings() { + localStorage.clear(); + window.location.reload(); +} async function pasteClipboard() { let t = await navigator.clipboard.readText(); if (regex.test(t)) { @@ -314,7 +323,7 @@ async function download(url) { let format = ``; if (audioMode === "false") { if (url.includes("youtube.com/") || url.includes("/youtu.be/")) { - format = `&format=${sGet("ytFormat")}` + format = `&format=${sGet("vFormat")}` } else if ((url.includes("tiktok.com/") || url.includes("douyin.com/")) && sGet("disableTikTokWatermark") === "true") { format = `&nw=true` } @@ -322,8 +331,8 @@ async function download(url) { format = `&nw=true` if (sGet("fullTikTokAudio") === "true") format += `&ttfull=true` } - let mode = (sGet("audioMode") === "true") ? `audio=true` : `quality=${sGet("quality")}` - await fetch(`/api/json?audioFormat=${sGet("defaultAudioFormat")}&${mode}${format}&url=${encodeURIComponent(url)}`).then(async (r) => { + let mode = (sGet("audioMode") === "true") ? `audio=true` : `quality=${sGet("vQuality")}` + await fetch(`/api/json?audioFormat=${sGet("aFormat")}&${mode}${format}&url=${encodeURIComponent(url)}`).then(async (r) => { let j = await r.json(); if (j.status !== "error" && j.status !== "rate-limit") { if (j.url) { diff --git a/src/localization/languages/en.json b/src/localization/languages/en.json index c7df6e45..711093e6 100644 --- a/src/localization/languages/en.json +++ b/src/localization/languages/en.json @@ -53,8 +53,8 @@ "AccessibilityKeepDownloadButton": "keep the download button always visible", "SettingsEnableDownloadPopup": "ask for a way to save", "AccessibilityEnableDownloadPopup": "ask what to do with downloads", - "SettingsFormatDescription": "select webm if you want max quality available. webm videos are usually higher quality but ios devices can't play them natively.", - "SettingsQualityDescription": "if selected resolution isn't available, closest one gets picked instead. if you want to post a youtube video on twitter, then select a combination of mp4 and 720p. twitter likes videos like that way more.", + "SettingsFormatDescription": "select webm if you want max quality available. webm videos are usually higher bitrate, but ios devices can't play them natively.", + "SettingsQualityDescription": "if selected quality isn't available, closest one gets picked instead.\nif you want to post a youtube video on social media, then select a combination of mp4 and 720p. those videos are usually not in av1 codec, so they should play just fine basically everywhere.", "DonateSubtitle": "help me pay for hosting", "DonateDescription": "i don't really like crypto in its current state, but it's the only reliable way for me to receive money and pay for anything abroad.", "LinkGitHubIssues": ">> report issues and check out the source code on github", diff --git a/src/localization/languages/ru.json b/src/localization/languages/ru.json index 57710cb6..5f564446 100644 --- a/src/localization/languages/ru.json +++ b/src/localization/languages/ru.json @@ -53,8 +53,8 @@ "AccessibilityKeepDownloadButton": "оставлять кнопку скачивания на экране", "SettingsEnableDownloadPopup": "спрашивать, что делать при скачивании", "AccessibilityEnableDownloadPopup": "спрашивать, что делать с загрузками", - "SettingsFormatDescription": "выбирай webm, если хочешь максимальное качество. webm обычно лучше по качеству, но устройства на ios не могут проигрывать их без сторонних приложений.", - "SettingsQualityDescription": "если выбранное разрешение недоступно, то выбирается ближайшее к нему. если ты хочешь твитнуть загруженное видео, то выбирай комбинацию из mp4 и 720p. твиттер такие видео обычно воспринимает намного лучше.", + "SettingsFormatDescription": "выбирай webm, если хочешь максимальное качество. у webm видео битрейт обычно выше, но устройства на ios не могут проигрывать их без сторонних приложений.", + "SettingsQualityDescription": "если выбранное качество недоступно, то выбирается ближайшее к нему.\nесли ты хочешь опубликовать видео с youtube где-то в соц. сетях, то выбирай комбинацию из mp4 и 720p. у таких видео кодек обычно не av1, поэтому они должны работать практически везде.", "DonateSubtitle": "помоги мне платить за хостинг", "DonateDescription": "я не люблю крипто в его текущем состоянии, но у меня нет другого надёжного способа оплаты хостинга.", "LinkGitHubIssues": ">> сообщай о проблемах и смотри исходный код на гитхабе", diff --git a/src/modules/changelog/changelog.json b/src/modules/changelog/changelog.json index 20940e1f..94137436 100644 --- a/src/modules/changelog/changelog.json +++ b/src/modules/changelog/changelog.json @@ -1,10 +1,14 @@ { "current": { + "version": "4.2", + "title": "optimized quality picking and 8k video support", + "content": "- this update fixes quality picking that was accidentally broken in 4.0 update.\n- you now can download videos in 8k from youtube. why would you that? no idea. but i'm more than happy to give you this option.\n- default video quality for downloads from pc is now 1440p, and 720p for phones.\n- default video format is now mp4 for everyone.\n- default audio format is now mp3 for everyone.\n\nyou can always change new defaults back to whatever you prefer in settings.\n\nother changes:\n- added more clarity to quality picker description.\n- youtube video codecs are now right in the picker.\n- setup script is now easier to understand." + }, + "history": [{ "version": "4.1", "title": "better tiktok image downloads", "content": "here's what's up:\n- tiktok images are saved as .jpeg instead of .webp (finally, i know).\n- added support for image downloads from douyin.\n- fixed tiktok audio downloads from the image picker.\n- emoji in about button now changes on special occasions. be it halloween or christmas, {appName} will change just a tiny bit to fit in :D\n\nif you're not caught up with new stuff in {appName} 4.x yet, check out the previous changelog down below. there's a ton of stuff to like." - }, - "history": [{ + }, { "version": "4.0", "title": "better and faster than ever", "content": "this update has a ton of improvements and new features.\n\nchanges you probably care about:\n- {appName} now has support for recorded twitter spaces! download the previous conversation no matter how long it was.\n- download speeds from youtube are at least 10 times better now. you're welcome.\n- both video and audio length limits have been extended to 2 hours.\n- audio downloads from youtube, youtube music, twitter spaces, and soundcloud now have metadata! most often it's just title and artist, but when {appName} is able to get more info, it adds that metadata too.\n- tiktok downloads have been fixed, yet again, and if they ever break in the future, {appName} will fall back to downloading a less annoyingly watermarked video.\n- soundcloud downloads have been fixed, too.\n\nless notable changes:\n- currently experimenting with using mp3 as default audio format. if you set something other than mp3 before, it'll be set to mp3. you can always change it back in settings. let me know what you think about this.\n- \"download audio\" button from image picker no longer stays on the screen after popup was closed.\n- clipboard button now shows up depending on your browser's support for it.\n- you can no longer manually hide the clipboard button, 'cause it's unnecessary.\n- small internal improvements such as separation of changelog version and title.\n- fair bit of internal clean up.\n\nif you want to help me implement covers for downloaded audios, you can do it on github.\n\nfun fact: average {appName} user is 10 times cooler than a regular person." diff --git a/src/modules/pageRender/page.js b/src/modules/pageRender/page.js index e81ce65d..b8989afb 100644 --- a/src/modules/pageRender/page.js +++ b/src/modules/pageRender/page.js @@ -172,12 +172,12 @@ export default function(obj) { name: "downloads", title: loc(obj.lang, 'SettingsDownloadsSubtitle'), body: switcher({ - name: "quality", + name: "vQuality", subtitle: loc(obj.lang, 'SettingsQualitySubtitle'), explanation: loc(obj.lang, 'SettingsQualityDescription'), items: [{ "action": "max", - "text": loc(obj.lang, 'SettingsQualitySwitchMax') + "text": `${loc(obj.lang, 'SettingsQualitySwitchMax')}
(2160p+)` }, { "action": "hig", "text": `${loc(obj.lang, 'SettingsQualitySwitchHigh')}
(${quality.hig}p)` @@ -193,13 +193,15 @@ export default function(obj) { + settingsCategory({ name: "youtube", body: switcher({ - name: "ytFormat", + name: "vFormat", subtitle: loc(obj.lang, 'SettingsFormatSubtitle'), explanation: loc(obj.lang, 'SettingsFormatDescription'), items: [{ - "action": "mp4" + "action": "mp4", + "text": "mp4 (av1)" }, { - "action": "webm" + "action": "webm", + "text": "webm (vp9)" }] }) }) @@ -215,7 +217,7 @@ export default function(obj) { name: "general", title: loc(obj.lang, 'SettingsAudioTab'), body: switcher({ - name: "defaultAudioFormat", + name: "aFormat", subtitle: loc(obj.lang, 'SettingsFormatSubtitle'), explanation: loc(obj.lang, 'SettingsAudioFormatDescription'), items: audioFormats diff --git a/src/modules/services/bilibili.js b/src/modules/services/bilibili.js index 76fcf262..554f2f7f 100644 --- a/src/modules/services/bilibili.js +++ b/src/modules/services/bilibili.js @@ -15,7 +15,7 @@ export default async function(obj) { let streamData = JSON.parse(html.split('')[0]); if (streamData.data.timelength <= maxVideoDuration) { let video = streamData["data"]["dash"]["video"].filter((v) => { - if (!v["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/") && v["height"] !== 4320) return true; + if (!v["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/")) return true; }).sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth)); let audio = streamData["data"]["dash"]["audio"].filter((a) => { if (!a["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/")) return true; diff --git a/src/modules/services/vimeo.js b/src/modules/services/vimeo.js index ea7fc74d..5b887d5b 100644 --- a/src/modules/services/vimeo.js +++ b/src/modules/services/vimeo.js @@ -15,7 +15,7 @@ export default async function(obj) { let all = api["request"]["files"]["progressive"].sort((a, b) => Number(b.width) - Number(a.width)); let best = all[0] try { - if (obj.quality !== "max") { + if (obj.quality != "max") { let pref = parseInt(quality[obj.quality], 10) for (let i in all) { let currQuality = parseInt(all[i]["quality"].replace('p', ''), 10) diff --git a/src/modules/services/youtube.js b/src/modules/services/youtube.js index bf8717d3..dc2627e9 100644 --- a/src/modules/services/youtube.js +++ b/src/modules/services/youtube.js @@ -10,32 +10,32 @@ export default async function(obj) { let info = infoInitial.formats; if (!info[0]["isLive"]) { let videoMatch = [], fullVideoMatch = [], video = [], audio = info.filter((a) => { - if (!a["isHLS"] && !a["isDashMPD"] && a["hasAudio"] && !a["hasVideo"] && a["container"] === obj.format) return true; + if (!a["isHLS"] && !a["isDashMPD"] && a["hasAudio"] && !a["hasVideo"] && a["container"] == obj.format) return true; }).sort((a, b) => Number(b.bitrate) - Number(a.bitrate)); if (!obj.isAudioOnly) { video = info.filter((a) => { - if (!a["isHLS"] && !a["isDashMPD"] && a["hasVideo"] && a["container"] === obj.format && a["height"] !== 4320) { - if (obj.quality !== "max") { - if (a["hasAudio"] && mq[obj.quality] === a["height"]) { + if (!a["isHLS"] && !a["isDashMPD"] && a["hasVideo"] && a["container"] == obj.format) { + if (obj.quality != "max") { + if (a["hasAudio"] && mq[obj.quality] == a["height"]) { fullVideoMatch.push(a) - } else if (!a["hasAudio"] && mq[obj.quality] === a["height"]) { + } else if (!a["hasAudio"] && mq[obj.quality] == a["height"]) { videoMatch.push(a); } } return true } }).sort((a, b) => Number(b.bitrate) - Number(a.bitrate)); - if (obj.quality !== "max") { - if (videoMatch.length === 0) { + if (obj.quality != "max") { + if (videoMatch.length == 0) { let ss = selectQuality("youtube", obj.quality, video[0]["qualityLabel"].slice(0, 5).replace('p', '').trim()) videoMatch = video.filter((a) => { - if (a["qualityLabel"].slice(0, 5).replace('p', '').trim() === ss) return true; + if (a["qualityLabel"].slice(0, 5).replace('p', '').trim() == ss) return true; }) } else if (fullVideoMatch.length > 0) { videoMatch = [fullVideoMatch[0]] } } else videoMatch = [video[0]]; - if (obj.quality === "los") videoMatch = [video[video.length - 1]]; + if (obj.quality == "los") videoMatch = [video[video.length - 1]]; } let generalMeta = { title: infoInitial.videoDetails.title, diff --git a/src/modules/setup.js b/src/modules/setup.js index 4aa98a91..625d4beb 100644 --- a/src/modules/setup.js +++ b/src/modules/setup.js @@ -16,20 +16,20 @@ let final = () => { for (let i in ob) { appendFileSync(envPath, `${i}=${ob[i]}\n`) } - console.log(Bright("\nI've created a .env file with selfURL, port, and stream salt.")) + console.log(Bright("\nAwesome! I've created a fresh .env file for you.")) console.log(`${Bright("Now I'll run")} ${Cyan("npm install")} ${Bright("to install all dependencies. It shouldn't take long.\n\n")}`) execSync('npm install', { stdio: [0, 1, 2] }); - console.log(`\n\n${Green("All done!\n")}`) - console.log("You can re-run this script any time to update the configuration.") - console.log("\nYou're now ready to start the main project.\nHave fun!") + console.log(`\n\n${Cyan("All done!\n")}`) + console.log(Bright("You can re-run this script at any time to update the configuration.")) + console.log(Bright("\nYou're now ready to start cobalt. Simply run ") + Cyan("npm start") + Bright('!\nHave fun :)')) rl.close() } console.log( - `${Cyan("Welcome to cobalt!")}\n${Bright("We'll get you up and running in no time.\nLet's start by creating a ")}${Cyan(".env")}${Bright(" file. You can always change it later.")}` + `${Cyan("Welcome to cobalt!")}\n${Bright("Let's start by creating a new ")}${Cyan(".env")}${Bright(" file. You can always change it later.")}` ) console.log( - Bright("\nWhat's the selfURL we'll be running on? (localhost)") + Bright("\nWhat's the domain this instance will be running on? (localhost)\nExample: co.wukko.me") ) rl.question(q, r1 => { @@ -38,7 +38,7 @@ rl.question(q, r1 => { } else { ob['selfURL'] = `http://localhost` } - console.log(Bright("\nGreat! Now, what's the port we'll be running on? (9000)")) + console.log(Bright("\nGreat! Now, what's the port it'll be running on? (9000)")) rl.question(q, r2 => { if (!r1 && !r2) { ob['selfURL'] = `http://localhost:9000/` diff --git a/src/modules/stream/selectQuality.js b/src/modules/stream/selectQuality.js index a3195a7a..d21a5f23 100644 --- a/src/modules/stream/selectQuality.js +++ b/src/modules/stream/selectQuality.js @@ -7,12 +7,12 @@ function closest(goal, array) { } export default function(service, quality, maxQuality) { - if (quality === "max") return maxQuality; + if (quality == "max") return maxQuality; quality = parseInt(mq[quality], 10) maxQuality = parseInt(maxQuality, 10) - if (quality >= maxQuality || quality === maxQuality) return maxQuality; + if (quality >= maxQuality || quality == maxQuality) return maxQuality; if (quality < maxQuality) { if (services[service]["quality"][quality]) {