diff --git a/README.md b/README.md
index 6b26b9c..7c60b8f 100644
--- a/README.md
+++ b/README.md
@@ -14,9 +14,9 @@ It preserves original media quality so you get best downloads possible (unless y
### Video and audio
- bilibili.com
-- douyin
+- douyin (with or without watermark, preference set by user)
- Reddit
-- TikTok
+- TikTok (with or without watermark, preference set by user)
- Tumblr
- Twitter
- YouTube (with HDR support)
@@ -57,8 +57,8 @@ Take English or Russian localization from [this directory](https://github.com/wu
- [ ] Instagram support
- [ ] SoundCloud support
- [ ] Quality switching for bilibili
-- [ ] Find a way to get TikTok videos without a watermark
-- [ ] Add an option to keep watermark on TikTok videos
+- [x] Find a way to get TikTok videos without a watermark
+- [x] Add an option to keep watermark on TikTok videos
### Other
- [ ] Remake video quality picking
diff --git a/package.json b/package.json
index 4792ed8..dfd4675 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "cobalt",
"description": "save what you love",
- "version": "3.0",
+ "version": "3.1",
"author": "wukko",
"exports": "./src/cobalt.js",
"type": "module",
diff --git a/src/cobalt.js b/src/cobalt.js
index cb32770..fb45a3a 100644
--- a/src/cobalt.js
+++ b/src/cobalt.js
@@ -68,7 +68,8 @@ if (fs.existsSync('./.env')) {
req.query.format ? req.query.format.slice(0, 5) : "webm",
req.query.quality ? req.query.quality.slice(0, 3) : "max",
req.query.audioFormat ? req.query.audioFormat.slice(0, 4) : false,
- req.query.audio ? true : false
+ req.query.audio ? true : false,
+ req.query.nw ? true : false
)
res.status(j.status).json(j.body);
} else {
diff --git a/src/front/cobalt.css b/src/front/cobalt.css
index 10ea06e..5cf2fed 100644
--- a/src/front/cobalt.css
+++ b/src/front/cobalt.css
@@ -288,6 +288,7 @@ input[type="checkbox"] {
text-align: left;
float: left;
line-height: 1.7rem;
+ user-select: text;
}
#popup-title {
font-size: 1.5rem;
diff --git a/src/front/cobalt.js b/src/front/cobalt.js
index 9424d82..11151dc 100644
--- a/src/front/cobalt.js
+++ b/src/front/cobalt.js
@@ -15,6 +15,12 @@ let exceptions = { // fuck you apple
function eid(id) {
return document.getElementById(id)
}
+function sGet(id) {
+ return localStorage.getItem(id)
+}
+function sSet(id, value) {
+ localStorage.setItem(id, value)
+}
function enable(id) {
eid(id).dataset.enabled = "true";
}
@@ -31,7 +37,7 @@ function changeDownloadButton(action, text) {
switch (action) {
case 0:
eid("download-button").disabled = true
- if (localStorage.getItem("alwaysVisibleButton") == "true") {
+ if (sGet("alwaysVisibleButton") == "true") {
eid("download-button").value = text
eid("download-button").style.padding = '0 1rem'
} else {
@@ -69,7 +75,7 @@ function copy(id, data) {
}
function detectColorScheme() {
let theme = "auto";
- let localTheme = localStorage.getItem("theme");
+ let localTheme = sGet("theme");
if (localTheme) {
theme = localTheme;
} else if (!window.matchMedia) {
@@ -102,11 +108,11 @@ function popup(type, action, text) {
case "about":
let tabId = text ? text : "changelog";
if (tabId == "changelog") {
- localStorage.setItem("changelogStatus", version)
+ sSet("changelogStatus", version)
}
eid(`tab-button-${type}-${tabId}`).click();
eid("popup-about").style.visibility = vis(action);
- if (!localStorage.getItem("seenAbout")) localStorage.setItem("seenAbout", "true");
+ if (!sGet("seenAbout")) sSet("seenAbout", "true");
break;
case "settings":
eid(`tab-button-${type}-video`).click();
@@ -130,14 +136,14 @@ function popup(type, action, text) {
}
function changeSwitcher(li, b) {
if (b) {
- localStorage.setItem(li, b);
+ sSet(li, b);
for (i in switchers[li]) {
(switchers[li][i] == b) ? enable(`${li}-${b}`) : disable(`${li}-${switchers[li][i]}`)
}
if (li == "theme") detectColorScheme();
} else {
let pref = switchers[li][0];
- localStorage.setItem(li, pref);
+ sSet(li, pref);
if (isIOS && exceptions[li]) pref = exceptions[li];
for (i in switchers[li]) {
(switchers[li][i] == pref) ? enable(`${li}-${pref}`) : disable(`${li}-${switchers[li][i]}`)
@@ -151,47 +157,50 @@ function internetError() {
}
function checkbox(action) {
if (eid(action).checked) {
- localStorage.setItem(action, "true");
+ sSet(action, "true");
if (action == "alwaysVisibleButton") button();
} else {
- localStorage.setItem(action, "false");
+ sSet(action, "false");
if (action == "alwaysVisibleButton") button();
}
}
function loadSettings() {
- if (localStorage.getItem("alwaysVisibleButton") == "true") {
+ if (sGet("alwaysVisibleButton") == "true") {
eid("alwaysVisibleButton").checked = true;
eid("download-button").value = '>>'
eid("download-button").style.padding = '0 1rem';
}
- if (localStorage.getItem("downloadPopup") == "true" && !isIOS) {
+ if (sGet("downloadPopup") == "true" && !isIOS) {
eid("downloadPopup").checked = true;
}
- if (!localStorage.getItem("audioMode")) {
+ if (!sGet("audioMode")) {
toggle("audioMode")
}
- updateToggle("audioMode", localStorage.getItem("audioMode"))
+ if (sGet("disableTikTokWatermark") == "true") {
+ eid("disableTikTokWatermark").checked = true;
+ }
+ updateToggle("audioMode", sGet("audioMode"));
for (let i in switchers) {
- changeSwitcher(i, localStorage.getItem(i))
+ changeSwitcher(i, sGet(i))
}
}
function checkbox(action) {
if (eid(action).checked) {
- localStorage.setItem(action, "true");
+ sSet(action, "true");
if (action == "alwaysVisibleButton") button();
} else {
- localStorage.setItem(action, "false");
+ sSet(action, "false");
if (action == "alwaysVisibleButton") button();
}
}
function toggle(toggle) {
- let state = localStorage.getItem(toggle);
+ let state = sGet(toggle);
if (state) {
- localStorage.setItem(toggle, opposite(state))
+ sSet(toggle, opposite(state))
} else {
- localStorage.setItem(toggle, "false")
+ sSet(toggle, "false")
}
- updateToggle(toggle, localStorage.getItem(toggle))
+ updateToggle(toggle, sGet(toggle))
}
function updateToggle(toggle, state) {
switch(state) {
@@ -206,10 +215,19 @@ function updateToggle(toggle, state) {
async function download(url) {
changeDownloadButton(2, '...');
eid("url-input-area").disabled = true;
- let audioMode = localStorage.getItem("audioMode");
- let format = (url.includes("youtube.com/") && audioMode == "false" || url.includes("/youtu.be/") && audioMode == "false") ? `&format=${localStorage.getItem("ytFormat")}` : '';
- let mode = (localStorage.getItem("audioMode") == "true") ? `audio=true` : `quality=${localStorage.getItem("quality")}`;
- fetch(`/api/json?audioFormat=${localStorage.getItem("audioFormat")}&${mode}${format}&url=${encodeURIComponent(url)}`).then(async (response) => {
+ let audioMode = sGet("audioMode");
+ let format = ``;
+ if (audioMode == "false") {
+ if (url.includes("youtube.com/") || url.includes("/youtu.be/")) {
+ format = `&format=${sGet("ytFormat")}`
+ } else if ((url.includes("tiktok.com/") || url.includes("douyin.com/")) && sGet("disableTikTokWatermark") == "true") {
+ format = `&nw=true`
+ }
+ } else {
+ format = `&nw=true`
+ }
+ let mode = (sGet("audioMode") == "true") ? `audio=true` : `quality=${sGet("quality")}`
+ fetch(`/api/json?audioFormat=${sGet("audioFormat")}&${mode}${format}&url=${encodeURIComponent(url)}`).then(async (response) => {
let j = await response.json();
if (j.status != "error" && j.status != "rate-limit") {
if (j.url) {
@@ -220,7 +238,7 @@ async function download(url) {
changeDownloadButton(1, '>>')
eid("url-input-area").disabled = false
}, 3000)
- if (localStorage.getItem("downloadPopup") == "true") {
+ if (sGet("downloadPopup") == "true") {
popup('download', 1, j.url)
} else {
window.open(j.url, '_blank');
@@ -264,12 +282,17 @@ window.onload = () => {
eid("cobalt-main-box").style.visibility = 'visible';
eid("footer").style.visibility = 'visible';
eid("url-input-area").value = "";
- if (!localStorage.getItem("seenAbout")) {
+ if (!sGet("seenAbout")) {
popup('about', 1, "about");
- } else if (localStorage.getItem("changelogStatus") != `${version}` && localStorage.getItem("disableChangelog") != "true") {
+ } else if (sGet("changelogStatus") != `${version}` && sGet("disableChangelog") != "true") {
popup('about', 1, "changelog");
}
- if (isIOS) localStorage.setItem("downloadPopup", "true");
+ if (isIOS) sSet("downloadPopup", "true");
+ let urlQuery = new URLSearchParams(window.location.search).get("u");
+ if (urlQuery != null) {
+ eid("url-input-area").value = urlQuery;
+ button();
+ }
}
eid("url-input-area").addEventListener("keyup", (event) => {
if (event.key === 'Enter') eid("download-button").click();
diff --git a/src/localization/languages/en.json b/src/localization/languages/en.json
index 108f5bd..0339eca 100644
--- a/src/localization/languages/en.json
+++ b/src/localization/languages/en.json
@@ -5,8 +5,8 @@
"ContactLink": "let me know"
},
"strings": {
- "ChangelogContentTitle": "everything what you've been waiting for. welcome to cobalt 3.0 :)",
- "ChangelogContent": "follow cobalt's twitter account for polls, updates, and more: @justusecobalt\n\nstuff that you can notice:\n\n- you can now download audio from any supported service, in any format that you set in settings (+). yes, that includes mp3, which you all have been waiting for :D\n- it's now easier to switch between download modes (just a single toggle on the bottom).\n- your youtube download format has been reset, sorry, but that was required to implement all audio downloads.\n- default download format for youtube videos on all platforms is now webm. except for ios.\n\n- cobalt now has emoji, just to spice up the black and white ui. all of them have been tuned to look the best in both themes. isn't it cool?\n- about, changelog, and donation popups have been merged into just one, for covnenience.\n- changelog got a huge upgrade (as you can see), and now there are both major changes and latest commit info, just so commits can finally go back to being batshit insane.\n- changelog popup appears on every major update, but you can disable it in settings, if you want to.\n- changelog now opens by default when pressing \"?\" button. i don't think anyone reads \"about\" as often.\n- settings (+) have been split into three tabs, also for convenience and ease of use.\n\n- added support for donation links. you can now donate through boosty, not only via crypto :D\n- donate popup has been rearranged and tuned just a tiny bit. \n\n- you can now click away from any popup by pressing the void behind it.\n- you can also press \"escape\" key on keyboard to close any popup.\n\n- switchers and buttons are now way easier on eye. white border is gone from where it's unneeded.\n- buttons are now very satisfying to press.\n- switchers are scrollable if there's not enough space to fit all contents on screen.\n- scaling is now even better than before.\n\ninternal stuff:\n\n- frontend won't send video related stuff if audio mode is on.\n- matching has, yet again, gone through mitosis, and is now probably the cleanest it can get.\n- page rendering is now modular, something like what frameworks have but way lighter. this makes adding new features WAY easier.\n- removed some stuff that didn't make sense (like storing language of stream request).\n- cleaned up insides of cobalt, of course.\n- almost all links now open in new tab, just like they should have from the very beginning.\n\nknown issues:\n- impossible to download audio from vk. i'll try to fix it in the next update.\n- headers are not sticky in tabbed popups. maybe this is a good thing, i'll think about it.\n\nif you ever notice any issues, make sure to report them on github. your report doesn't have to sound professional, just do your best to describe the issue.",
+ "ChangelogContentTitle": "small quality of life improvements (3.1)",
+ "ChangelogContent": "- tiktok videos can now be downloaded without watermark, you just have to enable it in video settings (+)!\n- you now can pass \"u\" query to main website to fill out the input area right away (co.wukko.me?u=your_link_here).\n- added ability to select text in certain areas of website.\n- some internal stuff has been cleaned up.\n\nfollow cobalt's twitter account for polls, updates, and more: @justusecobalt\n\nprevious version:\n\neverything what you've been waiting for. welcome to cobalt 3.0 :)\n\nstuff that you can notice:\n\n- you can now download audio from any supported service, in any format that you set in settings (+). yes, that includes mp3, which you all have been waiting for :D\n- it's now easier to switch between download modes (just a single toggle on the bottom).\n- your youtube download format has been reset, sorry, but that was required to implement all audio downloads.\n- default download format for youtube videos on all platforms is now webm. except for ios.\n\n- cobalt now has emoji, just to spice up the black and white ui. all of them have been tuned to look the best in both themes. isn't it cool?\n- about, changelog, and donation popups have been merged into just one, for covnenience.\n- changelog got a huge upgrade (as you can see), and now there are both major changes and latest commit info, just so commits can finally go back to being batshit insane.\n- changelog popup appears on every major update, but you can disable it in settings, if you want to.\n- changelog now opens by default when pressing \"?\" button. i don't think anyone reads \"about\" as often.\n- settings (+) have been split into three tabs, also for convenience and ease of use.\n\n- added support for donation links. you can now donate through boosty, not only via crypto :D\n- donate popup has been rearranged and tuned just a tiny bit. \n\n- you can now click away from any popup by pressing the void behind it.\n- you can also press \"escape\" key on keyboard to close any popup.\n\n- switchers and buttons are now way easier on eye. white border is gone from where it's unneeded.\n- buttons are now very satisfying to press.\n- switchers are scrollable if there's not enough space to fit all contents on screen.\n- scaling is now even better than before.\n\ninternal stuff:\n\n- frontend won't send video related stuff if audio mode is on.\n- matching has, yet again, gone through mitosis, and is now probably the cleanest it can get.\n- page rendering is now modular, something like what frameworks have but way lighter. this makes adding new features WAY easier.\n- removed some stuff that didn't make sense (like storing language of stream request).\n- cleaned up insides of cobalt, of course.\n- almost all links now open in new tab, just like they should have from the very beginning.\n\nknown issues:\n- impossible to download audio from vk. i'll try to fix it in the next update.\n- headers are not sticky in tabbed popups. maybe this is a good thing, i'll think about it.\n\nif you ever notice any issues, make sure to report them on github. your report doesn't have to sound professional, just do your best to describe the issue.",
"LinkInput": "paste the link here",
"AboutSummary": "{appName} is your go-to place for social media downloads. zero ads, trackers, or any other creepy bullshit attached. simply paste a share link and you're ready to rock!",
"AboutSupportedServices": "currently supported services:",
@@ -88,6 +88,7 @@
"SettingsAudioFormatBest": "best",
"SettingsAudioFormatDescription": "when best format is selected, you get audio in best quality available, because audio is kept in its original format. if you select anything other than that, you'll get a slightly compressed file.",
"Keyphrase": "save what you love",
- "SettingsDisableChangelogOnUpdate": "don't show changelog after major updates"
+ "SettingsDisableChangelogOnUpdate": "don't show changelog after major updates",
+ "SettingsRemoveWatermark": "disable watermark"
}
}
diff --git a/src/localization/languages/ru.json b/src/localization/languages/ru.json
index 19a6f16..d3b04f3 100644
--- a/src/localization/languages/ru.json
+++ b/src/localization/languages/ru.json
@@ -86,6 +86,7 @@
"SettingsAudioFormatBest": "лучший",
"SettingsAudioFormatDescription": "когда выбран \"лучший\" формат, ты получишь аудио максимально возможного качества, так как оно останется в оригинальном формате. если же выбрано что-то другое, то аудио будет немного сжато.",
"Keyphrase": "сохраняй то, что любишь",
- "SettingsDisableChangelogOnUpdate": "не показывать изменения после обновлений"
+ "SettingsDisableChangelogOnUpdate": "не показывать изменения после обновлений",
+ "SettingsRemoveWatermark": "убрать ватермарку"
}
}
diff --git a/src/modules/api.js b/src/modules/api.js
index 0d53760..30cc5e9 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -7,7 +7,7 @@ import { errorUnsupported } from "./sub/errors.js";
import loc from "../localization/manager.js";
import match from "./match.js";
-export async function getJSON(originalURL, ip, lang, format, quality, audioFormat, isAudioOnly) {
+export async function getJSON(originalURL, ip, lang, format, quality, audioFormat, isAudioOnly, noWatermark) {
try {
let url = decodeURI(originalURL);
if (!url.includes('http://')) {
@@ -28,7 +28,7 @@ export async function getJSON(originalURL, ip, lang, format, quality, audioForma
if (patternMatch) break;
}
if (patternMatch) {
- return await match(host, patternMatch, url, ip, lang, format, quality, audioFormat, isAudioOnly);
+ return await match(host, patternMatch, url, ip, lang, format, quality, audioFormat, isAudioOnly, noWatermark);
} return apiJSON(0, { t: errorUnsupported(lang) })
} return apiJSON(0, { t: errorUnsupported(lang) })
} else {
diff --git a/src/modules/match.js b/src/modules/match.js
index 5d03d63..66cd483 100644
--- a/src/modules/match.js
+++ b/src/modules/match.js
@@ -14,7 +14,7 @@ import tumblr from "./services/tumblr.js";
import matchActionDecider from "./sub/matchActionDecider.js";
import vimeo from "./services/vimeo.js";
-export default async function (host, patternMatch, url, ip, lang, format, quality, audioFormat, isAudioOnly) {
+export default async function (host, patternMatch, url, ip, lang, format, quality, audioFormat, isAudioOnly, noWatermark) {
try {
if (!testers[host]) return apiJSON(0, { t: errorUnsupported(lang) });
if (!(testers[host](patternMatch))) throw Error();
@@ -70,13 +70,13 @@ export default async function (host, patternMatch, url, ip, lang, format, qualit
case "tiktok":
r = await tiktok({
postId: patternMatch["postId"],
- id: patternMatch["id"], lang: lang,
+ id: patternMatch["id"], lang: lang, noWatermark: noWatermark
});
break;
case "douyin":
r = await douyin({
postId: patternMatch["postId"],
- id: patternMatch["id"], lang: lang,
+ id: patternMatch["id"], lang: lang, noWatermark: noWatermark
});
break;
case "tumblr":
diff --git a/src/modules/pageRender/page.js b/src/modules/pageRender/page.js
index ff965e5..47db6e1 100644
--- a/src/modules/pageRender/page.js
+++ b/src/modules/pageRender/page.js
@@ -196,6 +196,11 @@ export default function(obj) {
}]
})
})
+ + settingsCategory({
+ name: "tiktok",
+ title: "tiktok & douyin",
+ body: checkbox("disableTikTokWatermark", loc(obj.lang, 'SettingsRemoveWatermark'), loc(obj.lang, 'SettingsRemoveWatermark'))
+ })
}, {
name: "audio",
title: `${emoji("🎶")} ${loc(obj.lang, 'SettingsAudioTab')}`,
diff --git a/src/modules/services/douyin.js b/src/modules/services/douyin.js
index 8197807..631b404 100644
--- a/src/modules/services/douyin.js
+++ b/src/modules/services/douyin.js
@@ -30,7 +30,19 @@ export default async function(obj) {
});
iteminfo = JSON.parse(iteminfo.body);
if (iteminfo['item_list'][0]['video']['play_addr']['url_list'][0]) {
- return { urls: iteminfo['item_list'][0]['video']['play_addr']['url_list'][0].replace("playwm", "play"), audioFilename: `douyin_${obj.postId}_audio`, filename: `douyin_${obj.postId}.mp4` };
+ if (!obj.noWatermark) {
+ return {
+ urls: iteminfo['item_list'][0]['video']['play_addr']['url_list'][0],
+ audioFilename: `douyin_${obj.postId}_audio`,
+ filename: `douyin_${obj.postId}.mp4`
+ };
+ } else {
+ return {
+ urls: iteminfo['item_list'][0]['video']['play_addr']['url_list'][0].replace("playwm", "play"),
+ audioFilename: `douyin_${obj.postId}_audio`,
+ filename: `douyin_${obj.postId}_nw.mp4`
+ };
+ }
} else {
return { error: loc(obj.lang, 'ErrorEmptyDownload') };
}
diff --git a/src/modules/services/tiktok.js b/src/modules/services/tiktok.js
index cf982cf..9122b90 100644
--- a/src/modules/services/tiktok.js
+++ b/src/modules/services/tiktok.js
@@ -17,15 +17,28 @@ export default async function(obj) {
obj.postId = html.split('aweme/detail/')[1].split('?')[0]
}
}
- let html = await got.get(`https://tiktok.com/@video/video/${obj.postId}`, { headers: { "user-agent": genericUserAgent } });
- html.on('error', (err) => {
- return { error: loc(obj.lang, 'ErrorCantConnectToServiceAPI', 'tiktok') };
- });
- html = html.body;
- if (html.includes(',"preloadList":[{"url":"')) {
- return { urls: unicodeDecode(html.split(',"preloadList":[{"url":"')[1].split('","id":"')[0].trim()), audioFilename: `tiktok_${obj.postId}_audio`, filename: `tiktok_${obj.postId}.mp4` };
+ if (!obj.noWatermark) {
+ let html = await got.get(`https://tiktok.com/@video/video/${obj.postId}`, { headers: { "user-agent": genericUserAgent } });
+ html.on('error', (err) => {
+ return { error: loc(obj.lang, 'ErrorCantConnectToServiceAPI', 'tiktok') };
+ });
+ html = html.body;
+ if (html.includes(',"preloadList":[{"url":"')) {
+ return { urls: unicodeDecode(html.split(',"preloadList":[{"url":"')[1].split('","id":"')[0].trim()), audioFilename: `tiktok_${obj.postId}_audio`, filename: `tiktok_${obj.postId}.mp4` };
+ } else {
+ return { error: loc(obj.lang, 'ErrorEmptyDownload') };
+ }
} else {
- return { error: loc(obj.lang, 'ErrorEmptyDownload') };
+ let detail = await got.get(`https://api.tiktokv.com/aweme/v1/aweme/detail/?aweme_id=${obj.postId}`);
+ detail.on('error', (err) => {
+ return { error: loc(obj.lang, 'ErrorCantConnectToServiceAPI', 'tiktok') };
+ });
+ detail = JSON.parse(detail.body);
+ if (detail["aweme_detail"]["video"]["play_addr"]["url_list"][0]) {
+ return { urls: detail["aweme_detail"]["video"]["play_addr"]["url_list"][0], audioFilename: `tiktok_${obj.postId}_audio`, filename: `tiktok_${obj.postId}_nw.mp4` };
+ } else {
+ return { error: loc(obj.lang, 'ErrorEmptyDownload') };
+ }
}
} catch (e) {
return { error: loc(obj.lang, 'ErrorBadFetch') };