Compare commits
236 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
6a13ca347d | ||
|
9eb342e6d2 | ||
|
e497ea51f1 | ||
|
a8bffc4b27 | ||
|
3295032882 | ||
|
93ff9b62d6 | ||
|
5850b1ac87 | ||
|
97fee5e6d4 | ||
|
903998913f | ||
|
2197d9411e | ||
|
e6e2fea870 | ||
|
429591c445 | ||
|
95a5a8ae9b | ||
|
a5172b8fb4 | ||
|
1b0be14175 | ||
|
4a5f0aa52c | ||
|
1f0abf5169 | ||
|
1137ccfd3b | ||
|
714e71751e | ||
|
3935396709 | ||
|
7dc2683180 | ||
|
dab88f7ed8 | ||
|
187bf9d745 | ||
|
a84b21a501 | ||
|
4a1780ab7f | ||
|
6a4de1be28 | ||
|
d8b274f554 | ||
|
0bee4b1ade | ||
|
a3a273a4b1 | ||
|
158ba6f28f | ||
|
d98cb4c2d7 | ||
|
f9c0decd4c | ||
|
9225b31986 | ||
|
066a47c82d | ||
|
1f38bf822c | ||
|
e8967c33d3 | ||
|
4f92ccf813 | ||
|
7e71701e10 | ||
|
a2e08b9ccb | ||
|
bf0b9f55e5 | ||
|
698905db2e | ||
|
712318612d | ||
|
8af4c69be3 | ||
|
e61ac61e20 | ||
|
a3c9ccf5df | ||
|
6e21fc56eb | ||
|
ef7fc8781b | ||
|
0d3044c5e6 | ||
|
fd5f7c36b2 | ||
|
6b09bd4688 | ||
|
66401c6c5f | ||
|
64680e162a | ||
|
96142a3a0c | ||
|
3651b98b2d | ||
|
dc0803d292 | ||
|
8934b25c47 | ||
|
238295888c | ||
|
f5b9f59e43 | ||
|
0b631b31b3 | ||
|
b4dd9efd92 | ||
|
36de546fe2 | ||
|
78db8d5eef | ||
|
2573089378 | ||
|
c45c1d13c0 | ||
|
631f8bddd8 | ||
|
ad9fd4f601 | ||
|
20d24eca43 | ||
|
ceee059ecf | ||
|
78a4c9adbf | ||
|
0f21c9b236 | ||
|
104c9004c5 | ||
|
0ae5cad2f5 | ||
|
24a75eaf80 | ||
|
384ea412ea | ||
|
346b9084b0 | ||
|
bbc7629190 | ||
|
137fdd8c03 | ||
|
010dfff672 | ||
|
20c45823ee | ||
|
60f4009947 | ||
|
efa09d7280 | ||
|
33dd4b9fd8 | ||
|
3e2c7a3c91 | ||
|
ded23ec29a | ||
|
424a16729e | ||
|
910e889f60 | ||
|
5fa5a0e7cb | ||
|
910cbcf236 | ||
|
2e317c3abe | ||
|
969058d70b | ||
|
52528ddee8 | ||
|
b2df289894 | ||
|
8e4d0cd03d | ||
|
89fccae33d | ||
|
b463ec7a7d | ||
|
540aee6194 | ||
|
dcc99f0e62 | ||
|
3d98b4f9e4 | ||
|
dcc5b5d2fd | ||
|
bc70cf4b6b | ||
|
8d7f0d984f | ||
|
935947cafc | ||
|
553b3f9091 | ||
|
c0b671e45f | ||
|
564fc65297 | ||
|
ff62a4c2e6 | ||
|
c31c484894 | ||
|
fa267ae54b | ||
|
ad23b70e9d | ||
|
fb739f5315 | ||
|
ce510a5746 | ||
|
ca3263f1f3 | ||
|
adaf502d66 | ||
|
039ccf91be | ||
|
95d9913e3e | ||
|
dc33c07b39 | ||
|
1f79bf6e52 | ||
|
cff47da742 | ||
|
7a042e3bfa | ||
|
0ce777cbfc | ||
|
23f28acff0 | ||
|
c8ea19a69c | ||
|
4f50b44e68 | ||
|
c5d8d33870 | ||
|
62dccf7b51 | ||
|
88d4b4dc7c | ||
|
1716c1d2af | ||
|
6c18f1d460 | ||
|
161b3a7e3c | ||
|
de5a2d10ca | ||
|
12ea601e6d | ||
|
c8ecf41b10 | ||
|
945f87d93b | ||
|
19a342457b | ||
|
61efa619a2 | ||
|
50df95b212 | ||
|
5464574a3e | ||
|
0a8323be54 | ||
|
ee459e8694 | ||
|
90dcc48cad | ||
|
590b42a574 | ||
|
ef08633bdb | ||
|
00d376d4ac | ||
|
6513ab38d0 | ||
|
a7c1317af7 | ||
|
2ae0fd01dd | ||
|
398c5402d2 | ||
|
cdfb6e0fd9 | ||
|
1590490db2 | ||
|
f2325bdc24 | ||
|
7caee22aee | ||
|
d15f1ec8f2 | ||
|
00106e9379 | ||
|
fd1a7530ed | ||
|
b7997c220e | ||
|
5d7724762d | ||
|
affe49474d | ||
|
91f5d63b93 | ||
|
1c34d2daff | ||
|
b6472d5406 | ||
|
3a96c8ae56 | ||
|
e7d4b72c8c | ||
|
a43e7a629b | ||
|
c7c9cf2f0f | ||
|
75cda47633 | ||
|
c5e7b29c6c | ||
|
4f2c19b680 | ||
|
af18bcd43f | ||
|
7c3e1e6779 | ||
|
c3cc6c09f4 | ||
|
73d2f45dae | ||
|
de66ac6b08 | ||
|
d4684fa1f7 | ||
|
1e6b1cb201 | ||
|
44a99bdb3a | ||
|
c4c47bdc27 | ||
|
192635f2ce | ||
|
2279b5d845 | ||
|
2273bb388f | ||
|
8a5b25b4ce | ||
|
b85771dc1d | ||
|
cc3e3be118 | ||
|
28eb9ebe5d | ||
|
8e9347b4a0 | ||
|
2812960088 | ||
|
f544768784 | ||
|
0e26424355 | ||
|
1ed2eef65a | ||
|
28d8927c08 | ||
|
2f2d39dc4c | ||
|
d649a00718 | ||
|
302ff4ff29 | ||
|
e02e7f2260 | ||
|
2b95af1b51 | ||
|
a892a37c53 | ||
|
abc4673af7 | ||
|
f816fae6ba | ||
|
2272bb5edd | ||
|
f0e67fb69f | ||
|
c8bd08a290 | ||
|
0749106b96 | ||
|
4b5fd1cda0 | ||
|
a6069f406f | ||
|
45e7b69937 | ||
|
806a644a40 | ||
|
41600dab4f | ||
|
a9515d376a | ||
|
52b7f9523f | ||
|
78d0670f50 | ||
|
06c348126e | ||
|
fec07d0e10 | ||
|
f5b47a2b7e | ||
|
6e6a792984 | ||
|
05e0f031ed | ||
|
11388cb418 | ||
|
bf4675a5e3 | ||
|
bc597c817f | ||
|
f06aa65801 | ||
|
e7c2872e40 | ||
|
5820736a31 | ||
|
06000cbc77 | ||
|
8c9f7ff36d | ||
|
73d0b24aaf | ||
|
5860efa620 | ||
|
f3ff3656ef | ||
|
eba8dc3767 | ||
|
3f46395bd2 | ||
|
a8bb64ffb1 | ||
|
13ec4f4faf | ||
|
fcab598ec4 | ||
|
11e3d7a8f4 | ||
|
13c4438a57 | ||
|
45434ba751 | ||
|
6d0ec5dd85 | ||
|
5d75ee493d | ||
|
91327220a0 |
@ -71,7 +71,7 @@ as long as you:
|
|||||||
|
|
||||||
## open source acknowledgements
|
## open source acknowledgements
|
||||||
### ffmpeg
|
### ffmpeg
|
||||||
cobalt relies on ffmpeg for muxing and encoding media files. ffmpeg is absolutely spectacular and we're privileged to have an ability to use it for free, just like anyone else. we believe it should be way more recognized.
|
cobalt relies on ffmpeg for muxing and encoding media files. ffmpeg is absolutely spectacular and we're privileged to have the ability to use it for free, just like anyone else. we believe it should be way more recognized.
|
||||||
|
|
||||||
you can [support ffmpeg here](https://ffmpeg.org/donations.html)!
|
you can [support ffmpeg here](https://ffmpeg.org/donations.html)!
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@imput/cobalt-api",
|
"name": "@imput/cobalt-api",
|
||||||
"description": "save what you love",
|
"description": "save what you love",
|
||||||
"version": "10.8.2",
|
"version": "10.7.10",
|
||||||
"author": "imput",
|
"author": "imput",
|
||||||
"exports": "./src/cobalt.js",
|
"exports": "./src/cobalt.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -11,6 +11,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/cobalt",
|
"start": "node src/cobalt",
|
||||||
"test": "node src/util/test",
|
"test": "node src/util/test",
|
||||||
|
"token:youtube": "node src/util/generate-youtube-tokens",
|
||||||
"token:jwt": "node src/util/generate-jwt-secret"
|
"token:jwt": "node src/util/generate-jwt-secret"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
@ -34,11 +35,12 @@
|
|||||||
"ffmpeg-static": "^5.1.0",
|
"ffmpeg-static": "^5.1.0",
|
||||||
"hls-parser": "^0.10.7",
|
"hls-parser": "^0.10.7",
|
||||||
"ipaddr.js": "2.2.0",
|
"ipaddr.js": "2.2.0",
|
||||||
|
"mime": "^4.0.4",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.0.9",
|
||||||
"set-cookie-parser": "2.6.0",
|
"set-cookie-parser": "2.6.0",
|
||||||
"undici": "^5.19.1",
|
"undici": "^5.19.1",
|
||||||
"url-pattern": "1.0.3",
|
"url-pattern": "1.0.3",
|
||||||
"youtubei.js": "^13.2.0",
|
"youtubei.js": "^13.1.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
@ -53,11 +53,7 @@ const env = {
|
|||||||
keyReloadInterval: 900,
|
keyReloadInterval: 900,
|
||||||
|
|
||||||
enabledServices,
|
enabledServices,
|
||||||
|
|
||||||
customInnertubeClient: process.env.CUSTOM_INNERTUBE_CLIENT,
|
customInnertubeClient: process.env.CUSTOM_INNERTUBE_CLIENT,
|
||||||
ytSessionServer: process.env.YOUTUBE_SESSION_SERVER,
|
|
||||||
ytSessionReloadInterval: 300,
|
|
||||||
ytSessionInnertubeClient: process.env.YOUTUBE_SESSION_INNERTUBE_CLIENT,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36";
|
const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36";
|
||||||
|
@ -8,20 +8,19 @@ import jwt from "../security/jwt.js";
|
|||||||
import stream from "../stream/stream.js";
|
import stream from "../stream/stream.js";
|
||||||
import match from "../processing/match.js";
|
import match from "../processing/match.js";
|
||||||
|
|
||||||
import { env, isCluster, setTunnelPort } from "../config.js";
|
import { env } from "../config.js";
|
||||||
import { extract } from "../processing/url.js";
|
import { extract } from "../processing/url.js";
|
||||||
import { Green, Bright, Cyan } from "../misc/console-text.js";
|
import { Bright, Cyan } from "../misc/console-text.js";
|
||||||
import { hashHmac } from "../security/secrets.js";
|
import { hashHmac } from "../security/secrets.js";
|
||||||
import { createStore } from "../store/redis-ratelimit.js";
|
import { createStore } from "../store/redis-ratelimit.js";
|
||||||
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
|
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
|
||||||
import { verifyTurnstileToken } from "../security/turnstile.js";
|
import { verifyTurnstileToken } from "../security/turnstile.js";
|
||||||
import { friendlyServiceName } from "../processing/service-alias.js";
|
import { friendlyServiceName } from "../processing/service-alias.js";
|
||||||
import { verifyStream, getInternalStream } from "../stream/manage.js";
|
import { verifyStream } from "../stream/manage.js";
|
||||||
import { createResponse, normalizeRequest, getIP } from "../processing/request.js";
|
import { createResponse, normalizeRequest, getIP } from "../processing/request.js";
|
||||||
|
|
||||||
import * as APIKeys from "../security/api-keys.js";
|
import * as APIKeys from "../security/api-keys.js";
|
||||||
import * as Cookies from "../processing/cookie/manager.js";
|
import * as Cookies from "../processing/cookie/manager.js";
|
||||||
import * as YouTubeSession from "../processing/helpers/youtube-session.js";
|
import { setupTunnelHandler } from "./itunnel.js";
|
||||||
|
|
||||||
const git = {
|
const git = {
|
||||||
branch: await getBranch(),
|
branch: await getBranch(),
|
||||||
@ -62,13 +61,13 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleRateExceeded = (_, res) => {
|
const handleRateExceeded = (_, res) => {
|
||||||
const { status, body } = createResponse("error", {
|
const { body } = createResponse("error", {
|
||||||
code: "error.api.rate_exceeded",
|
code: "error.api.rate_exceeded",
|
||||||
context: {
|
context: {
|
||||||
limit: env.rateLimitWindow
|
limit: env.rateLimitWindow
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return res.status(status).json(body);
|
return res.status(429).json(body);
|
||||||
};
|
};
|
||||||
|
|
||||||
const keyGenerator = (req) => hashHmac(getIP(req), 'rate').toString('base64url');
|
const keyGenerator = (req) => hashHmac(getIP(req), 'rate').toString('base64url');
|
||||||
@ -265,6 +264,15 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.use('/tunnel', cors({
|
||||||
|
methods: ['GET'],
|
||||||
|
exposedHeaders: [
|
||||||
|
'Estimated-Content-Length',
|
||||||
|
'Content-Disposition'
|
||||||
|
],
|
||||||
|
...corsConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
app.get('/tunnel', apiTunnelLimiter, async (req, res) => {
|
app.get('/tunnel', apiTunnelLimiter, async (req, res) => {
|
||||||
const id = String(req.query.id);
|
const id = String(req.query.id);
|
||||||
const exp = String(req.query.exp);
|
const exp = String(req.query.exp);
|
||||||
@ -294,31 +302,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return stream(res, streamInfo);
|
return stream(res, streamInfo);
|
||||||
})
|
});
|
||||||
|
|
||||||
const itunnelHandler = (req, res) => {
|
|
||||||
if (!req.ip.endsWith('127.0.0.1')) {
|
|
||||||
return res.sendStatus(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (String(req.query.id).length !== 21) {
|
|
||||||
return res.sendStatus(400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamInfo = getInternalStream(req.query.id);
|
|
||||||
if (!streamInfo) {
|
|
||||||
return res.sendStatus(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
streamInfo.headers = new Map([
|
|
||||||
...(streamInfo.headers || []),
|
|
||||||
...Object.entries(req.headers)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return stream(res, { type: 'internal', data: streamInfo });
|
|
||||||
};
|
|
||||||
|
|
||||||
app.get('/itunnel', itunnelHandler);
|
|
||||||
|
|
||||||
app.get('/', (_, res) => {
|
app.get('/', (_, res) => {
|
||||||
res.type('json');
|
res.type('json');
|
||||||
@ -356,7 +340,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
|||||||
}, () => {
|
}, () => {
|
||||||
if (isPrimary) {
|
if (isPrimary) {
|
||||||
console.log(`\n` +
|
console.log(`\n` +
|
||||||
Bright(Cyan("cobalt ")) + Bright("API ^ω^") + "\n" +
|
Bright(Cyan("cobalt ")) + Bright("API ^ω^") + "\n" +
|
||||||
|
|
||||||
"~~~~~~\n" +
|
"~~~~~~\n" +
|
||||||
Bright("version: ") + version + "\n" +
|
Bright("version: ") + version + "\n" +
|
||||||
@ -378,23 +362,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
|||||||
if (env.cookiePath) {
|
if (env.cookiePath) {
|
||||||
Cookies.setup(env.cookiePath);
|
Cookies.setup(env.cookiePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (env.ytSessionServer) {
|
|
||||||
YouTubeSession.setup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isCluster) {
|
setupTunnelHandler();
|
||||||
const istreamer = express();
|
|
||||||
istreamer.get('/itunnel', itunnelHandler);
|
|
||||||
const server = istreamer.listen({
|
|
||||||
port: 0,
|
|
||||||
host: '127.0.0.1',
|
|
||||||
exclusive: true
|
|
||||||
}, () => {
|
|
||||||
const { port } = server.address();
|
|
||||||
console.log(`${Green('[✓]')} cobalt sub-instance running on 127.0.0.1:${port}`);
|
|
||||||
setTunnelPort(port);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
61
api/src/core/itunnel.js
Normal file
61
api/src/core/itunnel.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import stream from "../stream/stream.js";
|
||||||
|
import { getInternalTunnel } from "../stream/manage.js";
|
||||||
|
import { setTunnelPort } from "../config.js";
|
||||||
|
import { Green } from "../misc/console-text.js";
|
||||||
|
import express from "express";
|
||||||
|
|
||||||
|
const validateTunnel = (req, res) => {
|
||||||
|
if (!req.ip.endsWith('127.0.0.1')) {
|
||||||
|
res.sendStatus(403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(req.query.id).length !== 21) {
|
||||||
|
res.sendStatus(400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamInfo = getInternalTunnel(req.query.id);
|
||||||
|
if (!streamInfo) {
|
||||||
|
res.sendStatus(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamTunnel = (req, res) => {
|
||||||
|
const streamInfo = validateTunnel(req, res);
|
||||||
|
if (!streamInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamInfo.headers = new Map([
|
||||||
|
...(streamInfo.headers || []),
|
||||||
|
...Object.entries(req.headers)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return stream(res, { type: 'internal', data: streamInfo });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setupTunnelHandler = () => {
|
||||||
|
const tunnelHandler = express();
|
||||||
|
|
||||||
|
tunnelHandler.get('/itunnel', streamTunnel);
|
||||||
|
|
||||||
|
// fallback
|
||||||
|
tunnelHandler.use((_, res) => res.sendStatus(400));
|
||||||
|
// error handler
|
||||||
|
tunnelHandler.use((_, __, res, ____) => res.socket.end());
|
||||||
|
|
||||||
|
|
||||||
|
const server = tunnelHandler.listen({
|
||||||
|
port: 0,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
exclusive: true
|
||||||
|
}, () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
console.log(`${Green('[✓]')} internal tunnel handler running on 127.0.0.1:${port}`);
|
||||||
|
setTunnelPort(port);
|
||||||
|
});
|
||||||
|
}
|
@ -13,6 +13,7 @@ const VALID_SERVICES = new Set([
|
|||||||
'reddit',
|
'reddit',
|
||||||
'twitter',
|
'twitter',
|
||||||
'youtube',
|
'youtube',
|
||||||
|
'youtube_oauth'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const invalidCookies = {};
|
const invalidCookies = {};
|
||||||
|
@ -1,74 +0,0 @@
|
|||||||
import * as cluster from "../../misc/cluster.js";
|
|
||||||
|
|
||||||
import { env } from "../../config.js";
|
|
||||||
import { Green, Yellow } from "../../misc/console-text.js";
|
|
||||||
|
|
||||||
let session;
|
|
||||||
|
|
||||||
const validateSession = (sessionResponse) => {
|
|
||||||
if (!sessionResponse.potoken) {
|
|
||||||
throw "no poToken in session response";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionResponse.visitor_data) {
|
|
||||||
throw "no visitor_data in session response";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionResponse.updated) {
|
|
||||||
throw "no last update timestamp in session response";
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://github.com/iv-org/youtube-trusted-session-generator/blob/c2dfe3f/potoken_generator/main.py#L25
|
|
||||||
if (sessionResponse.potoken.length < 160) {
|
|
||||||
console.error(`${Yellow('[!]')} poToken is too short and might not work (${new Date().toISOString()})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateSession = (newSession) => {
|
|
||||||
session = newSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadSession = async () => {
|
|
||||||
const sessionServerUrl = new URL(env.ytSessionServer);
|
|
||||||
sessionServerUrl.pathname = "/token";
|
|
||||||
|
|
||||||
const newSession = await fetch(sessionServerUrl).then(a => a.json());
|
|
||||||
validateSession(newSession);
|
|
||||||
|
|
||||||
if (!session || session.updated < newSession?.updated) {
|
|
||||||
cluster.broadcast({ youtube_session: newSession });
|
|
||||||
updateSession(newSession);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrapLoad = (initial = false) => {
|
|
||||||
loadSession()
|
|
||||||
.then(() => {
|
|
||||||
if (initial) {
|
|
||||||
console.log(`${Green('[✓]')} poToken & visitor_data loaded successfully!`);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(`${Yellow('[!]')} Failed loading poToken & visitor_data at ${new Date().toISOString()}.`);
|
|
||||||
console.error('Error:', e);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getYouTubeSession = () => {
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setup = () => {
|
|
||||||
if (cluster.isPrimary) {
|
|
||||||
wrapLoad(true);
|
|
||||||
if (env.ytSessionReloadInterval > 0) {
|
|
||||||
setInterval(wrapLoad, env.ytSessionReloadInterval * 1000);
|
|
||||||
}
|
|
||||||
} else if (cluster.isWorker) {
|
|
||||||
process.on('message', (message) => {
|
|
||||||
if ('youtube_session' in message) {
|
|
||||||
updateSession(message.youtube_session);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -5,7 +5,22 @@ import { audioIgnore } from "./service-config.js";
|
|||||||
import { createStream } from "../stream/manage.js";
|
import { createStream } from "../stream/manage.js";
|
||||||
import { splitFilenameExtension } from "../misc/utils.js";
|
import { splitFilenameExtension } from "../misc/utils.js";
|
||||||
|
|
||||||
export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disableMetadata, filenameStyle, twitterGif, requestIP, audioBitrate, alwaysProxy }) {
|
const extraProcessingTypes = ["merge", "remux", "mute", "audio", "gif"];
|
||||||
|
|
||||||
|
export default function({
|
||||||
|
r,
|
||||||
|
host,
|
||||||
|
audioFormat,
|
||||||
|
isAudioOnly,
|
||||||
|
isAudioMuted,
|
||||||
|
disableMetadata,
|
||||||
|
filenameStyle,
|
||||||
|
convertGif,
|
||||||
|
requestIP,
|
||||||
|
audioBitrate,
|
||||||
|
alwaysProxy,
|
||||||
|
localProcessing
|
||||||
|
}) {
|
||||||
let action,
|
let action,
|
||||||
responseType = "tunnel",
|
responseType = "tunnel",
|
||||||
defaultParams = {
|
defaultParams = {
|
||||||
@ -22,7 +37,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
|
|||||||
|
|
||||||
if (r.isPhoto) action = "photo";
|
if (r.isPhoto) action = "photo";
|
||||||
else if (r.picker) action = "picker"
|
else if (r.picker) action = "picker"
|
||||||
else if (r.isGif && twitterGif) action = "gif";
|
else if (r.isGif && convertGif) action = "gif";
|
||||||
else if (isAudioOnly) action = "audio";
|
else if (isAudioOnly) action = "audio";
|
||||||
else if (isAudioMuted) action = "muteVideo";
|
else if (isAudioMuted) action = "muteVideo";
|
||||||
else if (r.isHLS) action = "hls";
|
else if (r.isHLS) action = "hls";
|
||||||
@ -216,5 +231,14 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
|
|||||||
params.type = "proxy";
|
params.type = "proxy";
|
||||||
}
|
}
|
||||||
|
|
||||||
return createResponse(responseType, {...defaultParams, ...params})
|
// TODO: add support for HLS
|
||||||
|
// (very painful)
|
||||||
|
if (localProcessing && !params.isHLS && extraProcessingTypes.includes(params.type)) {
|
||||||
|
responseType = "local-processing";
|
||||||
|
}
|
||||||
|
|
||||||
|
return createResponse(
|
||||||
|
responseType,
|
||||||
|
{ ...defaultParams, ...params }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -70,7 +70,7 @@ export default async function({ host, patternMatch, params }) {
|
|||||||
r = await twitter({
|
r = await twitter({
|
||||||
id: patternMatch.id,
|
id: patternMatch.id,
|
||||||
index: patternMatch.index - 1,
|
index: patternMatch.index - 1,
|
||||||
toGif: !!params.twitterGif,
|
toGif: !!params.convertGif,
|
||||||
alwaysProxy: params.alwaysProxy,
|
alwaysProxy: params.alwaysProxy,
|
||||||
dispatcher
|
dispatcher
|
||||||
});
|
});
|
||||||
@ -109,7 +109,7 @@ export default async function({ host, patternMatch, params }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.hostname === "music.youtube.com" || isAudioOnly) {
|
if (url.hostname === "music.youtube.com" || isAudioOnly) {
|
||||||
fetchInfo.quality = "1080";
|
fetchInfo.quality = "max";
|
||||||
fetchInfo.format = "vp9";
|
fetchInfo.format = "vp9";
|
||||||
fetchInfo.isAudioOnly = true;
|
fetchInfo.isAudioOnly = true;
|
||||||
fetchInfo.isAudioMuted = false;
|
fetchInfo.isAudioMuted = false;
|
||||||
@ -131,7 +131,7 @@ export default async function({ host, patternMatch, params }) {
|
|||||||
shortLink: patternMatch.shortLink,
|
shortLink: patternMatch.shortLink,
|
||||||
fullAudio: params.tiktokFullAudio,
|
fullAudio: params.tiktokFullAudio,
|
||||||
isAudioOnly,
|
isAudioOnly,
|
||||||
h265: params.tiktokH265,
|
h265: params.allowH265,
|
||||||
alwaysProxy: params.alwaysProxy,
|
alwaysProxy: params.alwaysProxy,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@ -243,7 +243,7 @@ export default async function({ host, patternMatch, params }) {
|
|||||||
case "xiaohongshu":
|
case "xiaohongshu":
|
||||||
r = await xiaohongshu({
|
r = await xiaohongshu({
|
||||||
...patternMatch,
|
...patternMatch,
|
||||||
h265: params.tiktokH265,
|
h265: params.allowH265,
|
||||||
isAudioOnly,
|
isAudioOnly,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
});
|
});
|
||||||
@ -300,10 +300,11 @@ export default async function({ host, patternMatch, params }) {
|
|||||||
isAudioMuted,
|
isAudioMuted,
|
||||||
disableMetadata: params.disableMetadata,
|
disableMetadata: params.disableMetadata,
|
||||||
filenameStyle: params.filenameStyle,
|
filenameStyle: params.filenameStyle,
|
||||||
twitterGif: params.twitterGif,
|
convertGif: params.convertGif,
|
||||||
requestIP,
|
requestIP,
|
||||||
audioBitrate: params.audioBitrate,
|
audioBitrate: params.audioBitrate,
|
||||||
alwaysProxy: params.alwaysProxy,
|
alwaysProxy: params.alwaysProxy,
|
||||||
|
localProcessing: params.localProcessing,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return createResponse("error", {
|
return createResponse("error", {
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
|
import mime from "mime";
|
||||||
import ipaddr from "ipaddr.js";
|
import ipaddr from "ipaddr.js";
|
||||||
|
|
||||||
import { createStream } from "../stream/manage.js";
|
|
||||||
import { apiSchema } from "./schema.js";
|
import { apiSchema } from "./schema.js";
|
||||||
|
import { createProxyTunnels, createStream } from "../stream/manage.js";
|
||||||
|
|
||||||
export function createResponse(responseType, responseData) {
|
export function createResponse(responseType, responseData) {
|
||||||
const internalError = (code) => {
|
const internalError = (code) => {
|
||||||
@ -49,6 +50,41 @@ export function createResponse(responseType, responseData) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "local-processing":
|
||||||
|
response = {
|
||||||
|
type: responseData?.type,
|
||||||
|
service: responseData?.service,
|
||||||
|
tunnel: createProxyTunnels(responseData),
|
||||||
|
|
||||||
|
output: {
|
||||||
|
type: mime.getType(responseData?.filename) || undefined,
|
||||||
|
filename: responseData?.filename,
|
||||||
|
metadata: responseData?.fileMetadata || undefined,
|
||||||
|
},
|
||||||
|
|
||||||
|
audio: {
|
||||||
|
copy: responseData?.audioCopy,
|
||||||
|
format: responseData?.audioFormat,
|
||||||
|
bitrate: responseData?.audioBitrate,
|
||||||
|
},
|
||||||
|
|
||||||
|
isHLS: responseData?.isHLS,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.audio.format) {
|
||||||
|
if (response.type === "audio") {
|
||||||
|
// audio response without a format is invalid
|
||||||
|
return internalError();
|
||||||
|
}
|
||||||
|
delete response.audio;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.output.type || !response.output.filename) {
|
||||||
|
// response without a type or filename is invalid
|
||||||
|
return internalError();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case "picker":
|
case "picker":
|
||||||
response = {
|
response = {
|
||||||
picker: responseData?.picker,
|
picker: responseData?.picker,
|
||||||
|
@ -36,15 +36,14 @@ export const apiSchema = z.object({
|
|||||||
.regex(/^[0-9a-zA-Z\-]+$/)
|
.regex(/^[0-9a-zA-Z\-]+$/)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
// TODO: remove this variable as it's no longer used
|
disableMetadata: z.boolean().default(false),
|
||||||
// and is kept for schema compatibility reasons
|
|
||||||
youtubeDubBrowserLang: z.boolean().default(false),
|
allowH265: z.boolean().default(false),
|
||||||
|
convertGif: z.boolean().default(true),
|
||||||
|
tiktokFullAudio: z.boolean().default(false),
|
||||||
|
|
||||||
alwaysProxy: z.boolean().default(false),
|
alwaysProxy: z.boolean().default(false),
|
||||||
disableMetadata: z.boolean().default(false),
|
localProcessing: z.boolean().default(false),
|
||||||
tiktokFullAudio: z.boolean().default(false),
|
|
||||||
tiktokH265: z.boolean().default(false),
|
|
||||||
twitterGif: z.boolean().default(true),
|
|
||||||
|
|
||||||
youtubeHLS: z.boolean().default(false),
|
youtubeHLS: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
|
@ -47,7 +47,8 @@ async function com_download(id) {
|
|||||||
return {
|
return {
|
||||||
urls: [video.baseUrl, audio.baseUrl],
|
urls: [video.baseUrl, audio.baseUrl],
|
||||||
audioFilename: `bilibili_${id}_audio`,
|
audioFilename: `bilibili_${id}_audio`,
|
||||||
filename: `bilibili_${id}_${video.width}x${video.height}.mp4`
|
filename: `bilibili_${id}_${video.width}x${video.height}.mp4`,
|
||||||
|
isHLS: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,8 +4,7 @@ import { fetch } from "undici";
|
|||||||
import { Innertube, Session } from "youtubei.js";
|
import { Innertube, Session } from "youtubei.js";
|
||||||
|
|
||||||
import { env } from "../../config.js";
|
import { env } from "../../config.js";
|
||||||
import { getCookie } from "../cookie/manager.js";
|
import { getCookie, updateCookieValues } from "../cookie/manager.js";
|
||||||
import { getYouTubeSession } from "../helpers/youtube-session.js";
|
|
||||||
|
|
||||||
const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
|
const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
|
||||||
|
|
||||||
@ -46,26 +45,41 @@ const clientsWithNoCipher = ['IOS', 'ANDROID', 'YTSTUDIO_ANDROID', 'YTMUSIC_ANDR
|
|||||||
|
|
||||||
const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320];
|
const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320];
|
||||||
|
|
||||||
const cloneInnertube = async (customFetch, useSession) => {
|
const transformSessionData = (cookie) => {
|
||||||
|
if (!cookie)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const values = { ...cookie.values() };
|
||||||
|
const REQUIRED_VALUES = ['access_token', 'refresh_token'];
|
||||||
|
|
||||||
|
if (REQUIRED_VALUES.some(x => typeof values[x] !== 'string')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.expires) {
|
||||||
|
values.expiry_date = values.expires;
|
||||||
|
delete values.expires;
|
||||||
|
} else if (!values.expiry_date) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloneInnertube = async (customFetch) => {
|
||||||
const shouldRefreshPlayer = lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date();
|
const shouldRefreshPlayer = lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date();
|
||||||
|
|
||||||
const rawCookie = getCookie('youtube');
|
const rawCookie = getCookie('youtube');
|
||||||
|
const rawCookieValues = rawCookie?.values();
|
||||||
const cookie = rawCookie?.toString();
|
const cookie = rawCookie?.toString();
|
||||||
|
|
||||||
const sessionTokens = getYouTubeSession();
|
|
||||||
const retrieve_player = Boolean(sessionTokens || cookie);
|
|
||||||
|
|
||||||
if (useSession && env.ytSessionServer && !sessionTokens?.potoken) {
|
|
||||||
throw "no_session_tokens";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!innertube || shouldRefreshPlayer) {
|
if (!innertube || shouldRefreshPlayer) {
|
||||||
innertube = await Innertube.create({
|
innertube = await Innertube.create({
|
||||||
fetch: customFetch,
|
fetch: customFetch,
|
||||||
retrieve_player,
|
retrieve_player: !!cookie,
|
||||||
cookie,
|
cookie,
|
||||||
po_token: useSession ? sessionTokens?.potoken : undefined,
|
po_token: rawCookieValues?.po_token,
|
||||||
visitor_data: useSession ? sessionTokens?.visitor_data : undefined,
|
visitor_data: rawCookieValues?.visitor_data,
|
||||||
});
|
});
|
||||||
lastRefreshedAt = +new Date();
|
lastRefreshedAt = +new Date();
|
||||||
}
|
}
|
||||||
@ -81,62 +95,73 @@ const cloneInnertube = async (customFetch, useSession) => {
|
|||||||
innertube.session.cache
|
innertube.session.cache
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const oauthCookie = getCookie('youtube_oauth');
|
||||||
|
const oauthData = transformSessionData(oauthCookie);
|
||||||
|
|
||||||
|
if (!session.logged_in && oauthData) {
|
||||||
|
await session.oauth.init(oauthData);
|
||||||
|
session.logged_in = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.logged_in && oauthData) {
|
||||||
|
if (session.oauth.shouldRefreshToken()) {
|
||||||
|
await session.oauth.refreshAccessToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieValues = oauthCookie.values();
|
||||||
|
const oldExpiry = new Date(cookieValues.expiry_date);
|
||||||
|
const newExpiry = new Date(session.oauth.oauth2_tokens.expiry_date);
|
||||||
|
|
||||||
|
if (oldExpiry.getTime() !== newExpiry.getTime()) {
|
||||||
|
updateCookieValues(oauthCookie, {
|
||||||
|
...session.oauth.client_id,
|
||||||
|
...session.oauth.oauth2_tokens,
|
||||||
|
expiry_date: newExpiry.toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const yt = new Innertube(session);
|
const yt = new Innertube(session);
|
||||||
return yt;
|
return yt;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function (o) {
|
export default async function (o) {
|
||||||
const quality = o.quality === "max" ? 9000 : Number(o.quality);
|
|
||||||
|
|
||||||
let useHLS = o.youtubeHLS;
|
|
||||||
let innertubeClient = o.innertubeClient || env.customInnertubeClient || "IOS";
|
|
||||||
|
|
||||||
// HLS playlists from the iOS client don't contain the av1 video format.
|
|
||||||
if (useHLS && o.format === "av1") {
|
|
||||||
useHLS = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useHLS) {
|
|
||||||
innertubeClient = "IOS";
|
|
||||||
}
|
|
||||||
|
|
||||||
// iOS client doesn't have adaptive formats of resolution >1080p,
|
|
||||||
// so we use the WEB_EMBEDDED client instead for those cases
|
|
||||||
const useSession =
|
|
||||||
env.ytSessionServer && (
|
|
||||||
(
|
|
||||||
!useHLS
|
|
||||||
&& innertubeClient === "IOS"
|
|
||||||
&& (
|
|
||||||
(quality > 1080 && o.format !== "h264")
|
|
||||||
|| (quality > 1080 && o.format !== "vp9")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (useSession) {
|
|
||||||
innertubeClient = env.ytSessionInnertubeClient || "WEB_EMBEDDED";
|
|
||||||
}
|
|
||||||
|
|
||||||
let yt;
|
let yt;
|
||||||
try {
|
try {
|
||||||
yt = await cloneInnertube(
|
yt = await cloneInnertube(
|
||||||
(input, init) => fetch(input, {
|
(input, init) => fetch(input, {
|
||||||
...init,
|
...init,
|
||||||
dispatcher: o.dispatcher
|
dispatcher: o.dispatcher
|
||||||
}),
|
})
|
||||||
useSession
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e === "no_session_tokens") {
|
if (e.message?.endsWith("decipher algorithm")) {
|
||||||
return { error: "youtube.no_session_tokens" };
|
|
||||||
} else if (e.message?.endsWith("decipher algorithm")) {
|
|
||||||
return { error: "youtube.decipher" }
|
return { error: "youtube.decipher" }
|
||||||
} else if (e.message?.includes("refresh access token")) {
|
} else if (e.message?.includes("refresh access token")) {
|
||||||
return { error: "youtube.token_expired" }
|
return { error: "youtube.token_expired" }
|
||||||
} else throw e;
|
} else throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cookie = getCookie('youtube')?.toString();
|
||||||
|
|
||||||
|
let useHLS = o.youtubeHLS;
|
||||||
|
|
||||||
|
// HLS playlists don't contain the av1 video format, at least with the iOS client
|
||||||
|
if (useHLS && o.format === "av1") {
|
||||||
|
useHLS = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let innertubeClient = o.innertubeClient || env.customInnertubeClient || "ANDROID";
|
||||||
|
|
||||||
|
if (cookie) {
|
||||||
|
useHLS = false;
|
||||||
|
innertubeClient = "WEB";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useHLS) {
|
||||||
|
innertubeClient = "IOS";
|
||||||
|
}
|
||||||
|
|
||||||
let info;
|
let info;
|
||||||
try {
|
try {
|
||||||
info = await yt.getBasicInfo(o.id, innertubeClient);
|
info = await yt.getBasicInfo(o.id, innertubeClient);
|
||||||
@ -215,6 +240,8 @@ export default async function (o) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const quality = o.quality === "max" ? 9000 : Number(o.quality);
|
||||||
|
|
||||||
const normalizeQuality = res => {
|
const normalizeQuality = res => {
|
||||||
const shortestSide = Math.min(res.height, res.width);
|
const shortestSide = Math.min(res.height, res.width);
|
||||||
return videoQualities.find(qual => qual >= shortestSide);
|
return videoQualities.find(qual => qual >= shortestSide);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import HLS from "hls-parser";
|
import HLS from "hls-parser";
|
||||||
import { createInternalStream } from "./manage.js";
|
import { createInternalStream } from "./manage.js";
|
||||||
|
import { request } from "undici";
|
||||||
|
|
||||||
function getURL(url) {
|
function getURL(url) {
|
||||||
try {
|
try {
|
||||||
@ -55,8 +56,11 @@ function transformMediaPlaylist(streamInfo, hlsPlaylist) {
|
|||||||
|
|
||||||
const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"];
|
const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"];
|
||||||
|
|
||||||
export function isHlsResponse (req) {
|
export function isHlsResponse(req, streamInfo) {
|
||||||
return HLS_MIME_TYPES.includes(req.headers['content-type']);
|
return HLS_MIME_TYPES.includes(req.headers['content-type'])
|
||||||
|
// bluesky's cdn responds with wrong content-type for the hls playlist,
|
||||||
|
// so we enforce it here until they fix it
|
||||||
|
|| (streamInfo.service === 'bsky' && streamInfo.url.endsWith('.m3u8'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleHlsPlaylist(streamInfo, req, res) {
|
export async function handleHlsPlaylist(streamInfo, req, res) {
|
||||||
@ -71,3 +75,67 @@ export async function handleHlsPlaylist(streamInfo, req, res) {
|
|||||||
|
|
||||||
res.send(hlsPlaylist);
|
res.send(hlsPlaylist);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getSegmentSize(url, config) {
|
||||||
|
const segmentResponse = await request(url, {
|
||||||
|
...config,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (segmentResponse.headers['content-length']) {
|
||||||
|
segmentResponse.body.dump();
|
||||||
|
return +segmentResponse.headers['content-length'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the response does not have a content-length
|
||||||
|
// header, we have to compute it ourselves
|
||||||
|
let size = 0;
|
||||||
|
|
||||||
|
for await (const data of segmentResponse.body) {
|
||||||
|
size += data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeInternalHLSTunnel(streamInfo) {
|
||||||
|
const { url, headers, dispatcher, signal } = streamInfo;
|
||||||
|
|
||||||
|
// remove all falsy headers
|
||||||
|
Object.keys(headers).forEach(key => {
|
||||||
|
if (!headers[key]) delete headers[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = { headers, dispatcher, signal, maxRedirections: 16 };
|
||||||
|
|
||||||
|
const manifestResponse = await fetch(url, config);
|
||||||
|
|
||||||
|
const manifest = HLS.parse(await manifestResponse.text());
|
||||||
|
if (manifest.segments.length === 0)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
const segmentSamples = await Promise.all(
|
||||||
|
Array(5).fill().map(async () => {
|
||||||
|
const manifestIdx = Math.floor(Math.random() * manifest.segments.length);
|
||||||
|
const randomSegment = manifest.segments[manifestIdx];
|
||||||
|
if (!randomSegment.uri)
|
||||||
|
throw "segment is missing URI";
|
||||||
|
|
||||||
|
let segmentUrl;
|
||||||
|
|
||||||
|
if (getURL(randomSegment.uri)) {
|
||||||
|
segmentUrl = new URL(randomSegment.uri);
|
||||||
|
} else {
|
||||||
|
segmentUrl = new URL(randomSegment.uri, streamInfo.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentSize = await getSegmentSize(segmentUrl, config) / randomSegment.duration;
|
||||||
|
return segmentSize;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const averageBitrate = segmentSamples.reduce((a, b) => a + b) / segmentSamples.length;
|
||||||
|
const totalDuration = manifest.segments.reduce((acc, segment) => acc + segment.duration, 0);
|
||||||
|
|
||||||
|
return averageBitrate * totalDuration;
|
||||||
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { request } from "undici";
|
import { request } from "undici";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import { closeRequest, getHeaders, pipe } from "./shared.js";
|
import { closeRequest, getHeaders, pipe } from "./shared.js";
|
||||||
import { handleHlsPlaylist, isHlsResponse } from "./internal-hls.js";
|
import { handleHlsPlaylist, isHlsResponse, probeInternalHLSTunnel } from "./internal-hls.js";
|
||||||
|
|
||||||
const CHUNK_SIZE = BigInt(8e6); // 8 MB
|
const CHUNK_SIZE = BigInt(8e6); // 8 MB
|
||||||
const min = (a, b) => a < b ? a : b;
|
const min = (a, b) => a < b ? a : b;
|
||||||
@ -118,10 +118,7 @@ async function handleGenericStream(streamInfo, res) {
|
|||||||
res.status(fileResponse.statusCode);
|
res.status(fileResponse.statusCode);
|
||||||
fileResponse.body.on('error', () => {});
|
fileResponse.body.on('error', () => {});
|
||||||
|
|
||||||
// bluesky's cdn responds with wrong content-type for the hls playlist,
|
const isHls = isHlsResponse(fileResponse, streamInfo);
|
||||||
// so we enforce it here until they fix it
|
|
||||||
const isHls = isHlsResponse(fileResponse)
|
|
||||||
|| (streamInfo.service === "bsky" && streamInfo.url.endsWith('.m3u8'));
|
|
||||||
|
|
||||||
for (const [ name, value ] of Object.entries(fileResponse.headers)) {
|
for (const [ name, value ] of Object.entries(fileResponse.headers)) {
|
||||||
if (!isHls || name.toLowerCase() !== 'content-length') {
|
if (!isHls || name.toLowerCase() !== 'content-length') {
|
||||||
@ -155,3 +152,40 @@ export function internalStream(streamInfo, res) {
|
|||||||
|
|
||||||
return handleGenericStream(streamInfo, res);
|
return handleGenericStream(streamInfo, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function probeInternalTunnel(streamInfo) {
|
||||||
|
try {
|
||||||
|
const signal = AbortSignal.timeout(3000);
|
||||||
|
const headers = {
|
||||||
|
...Object.fromEntries(streamInfo.headers || []),
|
||||||
|
...getHeaders(streamInfo.service),
|
||||||
|
host: undefined,
|
||||||
|
range: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
if (streamInfo.isHLS) {
|
||||||
|
return probeInternalHLSTunnel({
|
||||||
|
...streamInfo,
|
||||||
|
signal,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request(streamInfo.url, {
|
||||||
|
method: 'HEAD',
|
||||||
|
headers,
|
||||||
|
dispatcher: streamInfo.dispatcher,
|
||||||
|
signal,
|
||||||
|
maxRedirections: 16
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.statusCode !== 200)
|
||||||
|
throw "status is not 200 OK";
|
||||||
|
|
||||||
|
const size = +response.headers['content-length'];
|
||||||
|
if (isNaN(size))
|
||||||
|
throw "content-length is not a number";
|
||||||
|
|
||||||
|
return size;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
@ -70,10 +70,47 @@ export function createStream(obj) {
|
|||||||
return streamLink.toString();
|
return streamLink.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInternalStream(id) {
|
export function createProxyTunnels(info) {
|
||||||
|
const proxyTunnels = [];
|
||||||
|
|
||||||
|
let urls = info.url;
|
||||||
|
|
||||||
|
if (typeof urls === "string") {
|
||||||
|
urls = [urls];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const url of urls) {
|
||||||
|
proxyTunnels.push(
|
||||||
|
createStream({
|
||||||
|
url,
|
||||||
|
type: "proxy",
|
||||||
|
|
||||||
|
service: info?.service,
|
||||||
|
headers: info?.headers,
|
||||||
|
requestIP: info?.requestIP,
|
||||||
|
|
||||||
|
originalRequest: info?.originalRequest
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyTunnels;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInternalTunnel(id) {
|
||||||
return internalStreamCache.get(id);
|
return internalStreamCache.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getInternalTunnelFromURL(url) {
|
||||||
|
url = new URL(url);
|
||||||
|
if (url.hostname !== '127.0.0.1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = url.searchParams.get('id');
|
||||||
|
return getInternalTunnel(id);
|
||||||
|
}
|
||||||
|
|
||||||
export function createInternalStream(url, obj = {}) {
|
export function createInternalStream(url, obj = {}) {
|
||||||
assert(typeof url === 'string');
|
assert(typeof url === 'string');
|
||||||
|
|
||||||
@ -131,7 +168,7 @@ export function destroyInternalStream(url) {
|
|||||||
const id = getInternalTunnelId(url);
|
const id = getInternalTunnelId(url);
|
||||||
|
|
||||||
if (internalStreamCache.has(id)) {
|
if (internalStreamCache.has(id)) {
|
||||||
closeRequest(getInternalStream(id)?.controller);
|
closeRequest(getInternalTunnel(id)?.controller);
|
||||||
internalStreamCache.delete(id);
|
internalStreamCache.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { genericUserAgent } from "../config.js";
|
import { genericUserAgent } from "../config.js";
|
||||||
import { vkClientAgent } from "../processing/services/vk.js";
|
import { vkClientAgent } from "../processing/services/vk.js";
|
||||||
|
import { getInternalTunnelFromURL } from "./manage.js";
|
||||||
|
import { probeInternalTunnel } from "./internal.js";
|
||||||
|
|
||||||
const defaultHeaders = {
|
const defaultHeaders = {
|
||||||
'user-agent': genericUserAgent
|
'user-agent': genericUserAgent
|
||||||
@ -47,3 +49,40 @@ export function pipe(from, to, done) {
|
|||||||
|
|
||||||
from.pipe(to);
|
from.pipe(to);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function estimateTunnelLength(streamInfo, multiplier = 1.1) {
|
||||||
|
let urls = streamInfo.urls;
|
||||||
|
if (!Array.isArray(urls)) {
|
||||||
|
urls = [ urls ];
|
||||||
|
}
|
||||||
|
|
||||||
|
const internalTunnels = urls.map(getInternalTunnelFromURL);
|
||||||
|
if (internalTunnels.some(t => !t))
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
const sizes = await Promise.all(internalTunnels.map(probeInternalTunnel));
|
||||||
|
const estimatedSize = sizes.reduce(
|
||||||
|
// if one of the sizes is missing, let's just make a very
|
||||||
|
// bold guess that it's the same size as the existing one
|
||||||
|
(acc, cur) => cur <= 0 ? acc * 2 : acc + cur,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isNaN(estimatedSize) || estimatedSize <= 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.floor(estimatedSize * multiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateAudioMultiplier(streamInfo) {
|
||||||
|
if (streamInfo.audioFormat === 'wav') {
|
||||||
|
return 1411 / 128;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamInfo.audioCopy) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamInfo.audioBitrate / 128;
|
||||||
|
}
|
||||||
|
@ -10,20 +10,20 @@ export default async function(res, streamInfo) {
|
|||||||
return await stream.proxy(streamInfo, res);
|
return await stream.proxy(streamInfo, res);
|
||||||
|
|
||||||
case "internal":
|
case "internal":
|
||||||
return internalStream(streamInfo.data, res);
|
return await internalStream(streamInfo.data, res);
|
||||||
|
|
||||||
case "merge":
|
case "merge":
|
||||||
return stream.merge(streamInfo, res);
|
return await stream.merge(streamInfo, res);
|
||||||
|
|
||||||
case "remux":
|
case "remux":
|
||||||
case "mute":
|
case "mute":
|
||||||
return stream.remux(streamInfo, res);
|
return await stream.remux(streamInfo, res);
|
||||||
|
|
||||||
case "audio":
|
case "audio":
|
||||||
return stream.convertAudio(streamInfo, res);
|
return await stream.convertAudio(streamInfo, res);
|
||||||
|
|
||||||
case "gif":
|
case "gif":
|
||||||
return stream.convertGif(streamInfo, res);
|
return await stream.convertGif(streamInfo, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeResponse(res);
|
closeResponse(res);
|
||||||
|
@ -6,7 +6,7 @@ import { create as contentDisposition } from "content-disposition-header";
|
|||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
import { destroyInternalStream } from "./manage.js";
|
import { destroyInternalStream } from "./manage.js";
|
||||||
import { hlsExceptions } from "../processing/service-config.js";
|
import { hlsExceptions } from "../processing/service-config.js";
|
||||||
import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js";
|
import { getHeaders, closeRequest, closeResponse, pipe, estimateTunnelLength, estimateAudioMultiplier } from "./shared.js";
|
||||||
|
|
||||||
const ffmpegArgs = {
|
const ffmpegArgs = {
|
||||||
webm: ["-c:v", "copy", "-c:a", "copy"],
|
webm: ["-c:v", "copy", "-c:a", "copy"],
|
||||||
@ -29,7 +29,7 @@ const convertMetadataToFFmpeg = (metadata) => {
|
|||||||
|
|
||||||
for (const [ name, value ] of Object.entries(metadata)) {
|
for (const [ name, value ] of Object.entries(metadata)) {
|
||||||
if (metadataTags.includes(name)) {
|
if (metadataTags.includes(name)) {
|
||||||
args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`);
|
args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`); // skipcq: JS-0004
|
||||||
} else {
|
} else {
|
||||||
throw `${name} metadata tag is not supported.`;
|
throw `${name} metadata tag is not supported.`;
|
||||||
}
|
}
|
||||||
@ -98,7 +98,7 @@ const proxy = async (streamInfo, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const merge = (streamInfo, res) => {
|
const merge = async (streamInfo, res) => {
|
||||||
let process;
|
let process;
|
||||||
const shutdown = () => (
|
const shutdown = () => (
|
||||||
killProcess(process),
|
killProcess(process),
|
||||||
@ -112,7 +112,7 @@ const merge = (streamInfo, res) => {
|
|||||||
try {
|
try {
|
||||||
if (streamInfo.urls.length !== 2) return shutdown();
|
if (streamInfo.urls.length !== 2) return shutdown();
|
||||||
|
|
||||||
const format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1];
|
const format = streamInfo.filename.split('.').pop();
|
||||||
|
|
||||||
let args = [
|
let args = [
|
||||||
'-loglevel', '-8',
|
'-loglevel', '-8',
|
||||||
@ -152,6 +152,7 @@ const merge = (streamInfo, res) => {
|
|||||||
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
||||||
|
res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo));
|
||||||
|
|
||||||
pipe(muxOutput, res, shutdown);
|
pipe(muxOutput, res, shutdown);
|
||||||
|
|
||||||
@ -162,7 +163,7 @@ const merge = (streamInfo, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const remux = (streamInfo, res) => {
|
const remux = async (streamInfo, res) => {
|
||||||
let process;
|
let process;
|
||||||
const shutdown = () => (
|
const shutdown = () => (
|
||||||
killProcess(process),
|
killProcess(process),
|
||||||
@ -196,7 +197,7 @@ const remux = (streamInfo, res) => {
|
|||||||
args.push('-bsf:a', 'aac_adtstoasc');
|
args.push('-bsf:a', 'aac_adtstoasc');
|
||||||
}
|
}
|
||||||
|
|
||||||
let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1];
|
let format = streamInfo.filename.split('.').pop();
|
||||||
if (format === "mp4") {
|
if (format === "mp4") {
|
||||||
args.push('-movflags', 'faststart+frag_keyframe+empty_moov')
|
args.push('-movflags', 'faststart+frag_keyframe+empty_moov')
|
||||||
}
|
}
|
||||||
@ -215,6 +216,7 @@ const remux = (streamInfo, res) => {
|
|||||||
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
||||||
|
res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo));
|
||||||
|
|
||||||
pipe(muxOutput, res, shutdown);
|
pipe(muxOutput, res, shutdown);
|
||||||
|
|
||||||
@ -225,7 +227,7 @@ const remux = (streamInfo, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertAudio = (streamInfo, res) => {
|
const convertAudio = async (streamInfo, res) => {
|
||||||
let process;
|
let process;
|
||||||
const shutdown = () => (
|
const shutdown = () => (
|
||||||
killProcess(process),
|
killProcess(process),
|
||||||
@ -284,6 +286,13 @@ const convertAudio = (streamInfo, res) => {
|
|||||||
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
||||||
|
res.setHeader(
|
||||||
|
'Estimated-Content-Length',
|
||||||
|
await estimateTunnelLength(
|
||||||
|
streamInfo,
|
||||||
|
estimateAudioMultiplier(streamInfo) * 1.1
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
pipe(muxOutput, res, shutdown);
|
pipe(muxOutput, res, shutdown);
|
||||||
res.on('finish', shutdown);
|
res.on('finish', shutdown);
|
||||||
@ -292,7 +301,7 @@ const convertAudio = (streamInfo, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertGif = (streamInfo, res) => {
|
const convertGif = async (streamInfo, res) => {
|
||||||
let process;
|
let process;
|
||||||
const shutdown = () => (killProcess(process), closeResponse(res));
|
const shutdown = () => (killProcess(process), closeResponse(res));
|
||||||
|
|
||||||
@ -321,6 +330,7 @@ const convertGif = (streamInfo, res) => {
|
|||||||
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
||||||
|
res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo, 60));
|
||||||
|
|
||||||
pipe(muxOutput, res, shutdown);
|
pipe(muxOutput, res, shutdown);
|
||||||
|
|
||||||
|
38
api/src/util/generate-youtube-tokens.js
Normal file
38
api/src/util/generate-youtube-tokens.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Innertube } from 'youtubei.js';
|
||||||
|
import { Red } from '../misc/console-text.js'
|
||||||
|
|
||||||
|
const bail = (...msg) => {
|
||||||
|
console.error(...msg);
|
||||||
|
throw new Error(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tube = await Innertube.create();
|
||||||
|
|
||||||
|
tube.session.once(
|
||||||
|
'auth-pending',
|
||||||
|
({ verification_url, user_code }) => {
|
||||||
|
console.log(`${Red('[!]')} The token generated by this script is sensitive and you should not share it with anyone!`);
|
||||||
|
console.log(` By using this token, you are risking your Google account getting terminated.`);
|
||||||
|
console.log(` You should ${Red('NOT')} use your personal account!`);
|
||||||
|
console.log();
|
||||||
|
console.log(`Open ${verification_url} in a browser and enter ${user_code} when asked for the code.`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
tube.session.once('auth-error', (err) => bail('An error occurred:', err));
|
||||||
|
tube.session.once('auth', ({ credentials }) => {
|
||||||
|
if (!credentials.access_token) {
|
||||||
|
bail('something went wrong');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'add this cookie to the youtube_oauth array in your cookies file:',
|
||||||
|
JSON.stringify(
|
||||||
|
Object.entries(credentials)
|
||||||
|
.map(([k, v]) => `${k}=${v instanceof Date ? v.toISOString() : v}`)
|
||||||
|
.join('; ')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await tube.session.signIn();
|
@ -1,8 +1,7 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "video (might have expired)",
|
"name": "long link video",
|
||||||
"url": "https://www.xiaohongshu.com/explore/67cc17a3000000000e00726a?xsec_token=CBSFRtbF57so920elY1kbIX4fE1nhrwlpGZs9m6pIFpwo=",
|
"url": "https://www.xiaohongshu.com/discovery/item/6789065900000000210035fc?source=webshare&xhsshare=pc_web&xsec_token=CBustnz_Twf1BSybpe5-D-BzUb-Bx28DPLb418TN9S9Kk&xsec_source=pc_share",
|
||||||
"canFail": true,
|
|
||||||
"params": {},
|
"params": {},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
@ -10,9 +9,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "picker with multiple live photos (might have expired)",
|
"name": "picker with multiple live photos",
|
||||||
"url": "https://www.xiaohongshu.com/explore/67c691b4000000000d0159cc?xsec_token=CB8p1eyB5DiFkwlUpy1BTeVsI9oOve6ppNjuDzo8V8p5w=",
|
"url": "https://www.xiaohongshu.com/explore/67847fa1000000000203e6ed?xsec_token=CBzyP7Y44PPpsM20lgxqrIIJMHqOLemusDsRcmsX0cTpk",
|
||||||
"canFail": true,
|
|
||||||
"params": {},
|
"params": {},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
@ -20,9 +18,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "one photo (might have expired)",
|
"name": "one photo",
|
||||||
"url": "https://www.xiaohongshu.com/explore/676e132d000000000b016f68?xsec_token=ABRv6LKzizOFeSaf2HnnBkdBqniB5Ak1fI8tMAHzO31jA",
|
"url": "https://www.xiaohongshu.com/explore/676e132d000000000b016f68?xsec_token=ABRv6LKzizOFeSaf2HnnBkdBqniB5Ak1fI8tMAHzO31jA",
|
||||||
"canFail": true,
|
|
||||||
"params": {},
|
"params": {},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
@ -30,7 +27,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "short link (might have expired)",
|
"name": "short link, might expire eventually",
|
||||||
"url": "https://xhslink.com/a/czn4z6c1tic4",
|
"url": "https://xhslink.com/a/czn4z6c1tic4",
|
||||||
"canFail": true,
|
"canFail": true,
|
||||||
"params": {},
|
"params": {},
|
||||||
|
@ -189,7 +189,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hls video (h264, 1440p)",
|
"name": "hls video (h264, 1440p)",
|
||||||
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
|
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
|
||||||
"canFail": true,
|
|
||||||
"params": {
|
"params": {
|
||||||
"youtubeVideoCodec": "h264",
|
"youtubeVideoCodec": "h264",
|
||||||
"videoQuality": "1440",
|
"videoQuality": "1440",
|
||||||
@ -203,7 +202,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hls video (vp9, 360p)",
|
"name": "hls video (vp9, 360p)",
|
||||||
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
|
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
|
||||||
"canFail": true,
|
|
||||||
"params": {
|
"params": {
|
||||||
"youtubeVideoCodec": "vp9",
|
"youtubeVideoCodec": "vp9",
|
||||||
"videoQuality": "360",
|
"videoQuality": "360",
|
||||||
@ -217,7 +215,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hls video (audio mode)",
|
"name": "hls video (audio mode)",
|
||||||
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
|
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
|
||||||
"canFail": true,
|
|
||||||
"params": {
|
"params": {
|
||||||
"downloadMode": "audio",
|
"downloadMode": "audio",
|
||||||
"youtubeHLS": true
|
"youtubeHLS": true
|
||||||
@ -230,7 +227,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hls video (audio mode, best format)",
|
"name": "hls video (audio mode, best format)",
|
||||||
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
|
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
|
||||||
"canFail": true,
|
|
||||||
"params": {
|
"params": {
|
||||||
"downloadMode": "audio",
|
"downloadMode": "audio",
|
||||||
"youtubeHLS": true,
|
"youtubeHLS": true,
|
||||||
|
@ -68,8 +68,8 @@ Content-Type: application/json
|
|||||||
| `alwaysProxy` | `boolean` | `true / false` | `false` | tunnels all downloads through the processing server, even when not necessary. |
|
| `alwaysProxy` | `boolean` | `true / false` | `false` | tunnels all downloads through the processing server, even when not necessary. |
|
||||||
| `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. |
|
| `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. |
|
||||||
| `tiktokFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. |
|
| `tiktokFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. |
|
||||||
| `tiktokH265` | `boolean` | `true / false` | `false` | allows h265 videos when enabled. applies to tiktok & xiaohongshu. |
|
| `allowH265` | `boolean` | `true / false` | `false` | allows h265 videos when enabled. applies to tiktok & xiaohongshu. |
|
||||||
| `twitterGif` | `boolean` | `true / false` | `true` | changes whether twitter gifs are converted to .gif |
|
| `convertGif` | `boolean` | `true / false` | `true` | changes whether mute looping videos are converted to .gif |
|
||||||
| `youtubeHLS` | `boolean` | `true / false` | `false` | specifies whether to use HLS for downloading video or audio from youtube. |
|
| `youtubeHLS` | `boolean` | `true / false` | `false` | specifies whether to use HLS for downloading video or audio from youtube. |
|
||||||
|
|
||||||
### response
|
### response
|
||||||
|
33
docs/configure-for-youtube.md
Normal file
33
docs/configure-for-youtube.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# how to configure a cobalt instance for youtube
|
||||||
|
if you get various errors when attempting to download videos that are:
|
||||||
|
publicly available, not region locked, and not age-restricted;
|
||||||
|
then your instance's ip address may have bad reputation.
|
||||||
|
|
||||||
|
in this case you have to use disposable google accounts.
|
||||||
|
there's no other known workaround as of time of writing this document.
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **NEVER** use your personal google account for downloading videos via any means.
|
||||||
|
> you can use any google accounts that you're willing to sacrifice,
|
||||||
|
> but be prepared to have them **permanently suspended**.
|
||||||
|
>
|
||||||
|
> we recommend that you use accounts that don't link back to your personal google account or identity, just in case.
|
||||||
|
>
|
||||||
|
> use incognito mode when signing in.
|
||||||
|
> we also recommend using vpn/proxy services (such as [mullvad](https://mullvad.net/)).
|
||||||
|
|
||||||
|
1. if you haven't done it already, clone the cobalt repo, go to the cloned directory, and run `pnpm install`
|
||||||
|
|
||||||
|
2. run `pnpm -C api token:youtube`
|
||||||
|
|
||||||
|
3. follow instructions, use incognito mode in your browser when signing in.
|
||||||
|
i cannot stress this enough, but again, **DO NOT USE YOUR PERSONAL GOOGLE ACCOUNT**.
|
||||||
|
|
||||||
|
4. once you have the oauth token, add it to `youtube_oauth` in your cookies file.
|
||||||
|
you can see an [example here](/docs/examples/cookies.example.json).
|
||||||
|
you can have several account tokens in this file, if you like.
|
||||||
|
|
||||||
|
5. all done! enjoy freedom.
|
||||||
|
|
||||||
|
### liability
|
||||||
|
you're responsible for any damage done to any of your google accounts or any other damages. you do this by yourself and at your own risk.
|
@ -10,5 +10,8 @@
|
|||||||
],
|
],
|
||||||
"twitter": [
|
"twitter": [
|
||||||
"auth_token=<replace_this>; ct0=<replace_this>"
|
"auth_token=<replace_this>; ct0=<replace_this>"
|
||||||
|
],
|
||||||
|
"youtube_oauth": [
|
||||||
|
"<output from running `pnpm run token:youtube` in `api` folder goes here>"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -41,14 +41,3 @@ services:
|
|||||||
command: --cleanup --scope cobalt --interval 900 --include-restarting
|
command: --cleanup --scope cobalt --interval 900 --include-restarting
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
# if needed, use this image for automatically generating poToken & visitor_data
|
|
||||||
# yt-session-generator:
|
|
||||||
# image: ghcr.io/imputnet/yt-session-generator:webserver
|
|
||||||
|
|
||||||
# init: true
|
|
||||||
# restart: unless-stopped
|
|
||||||
# container_name: yt-session-generator
|
|
||||||
|
|
||||||
# ports:
|
|
||||||
# - 127.0.0.1:1280:8080
|
|
||||||
|
@ -81,7 +81,6 @@ sudo service nscd start
|
|||||||
| `API_INSTANCE_COUNT` | ➖ | `2` | supported only on Linux and node.js `>=23.1.0`. when configured, cobalt will spawn multiple sub-instances amongst which requests will be balanced. |
|
| `API_INSTANCE_COUNT` | ➖ | `2` | supported only on Linux and node.js `>=23.1.0`. when configured, cobalt will spawn multiple sub-instances amongst which requests will be balanced. |
|
||||||
| `DISABLED_SERVICES` | ➖ | `bilibili,youtube` | comma-separated list which disables certain services from being used. |
|
| `DISABLED_SERVICES` | ➖ | `bilibili,youtube` | comma-separated list which disables certain services from being used. |
|
||||||
| `CUSTOM_INNERTUBE_CLIENT` | ➖ | `IOS` | innertube client that will be used instead of the default one. |
|
| `CUSTOM_INNERTUBE_CLIENT` | ➖ | `IOS` | innertube client that will be used instead of the default one. |
|
||||||
| `YOUTUBE_SESSION_SERVER` | ➖ | `http://localhost:8080/` | url to an instance of [invidious' youtube-trusted-session-generator](https://github.com/iv-org/youtube-trusted-session-generator) or its fork/counterpart. used for automatically pulling poToken & visitor_data for youtube. can be local or remote. |
|
|
||||||
|
|
||||||
\* the higher the nice value, the lower the priority. [read more here](https://en.wikipedia.org/wiki/Nice_(Unix)).
|
\* the higher the nice value, the lower the priority. [read more here](https://en.wikipedia.org/wiki/Nice_(Unix)).
|
||||||
|
|
||||||
|
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@ -43,6 +43,9 @@ importers:
|
|||||||
ipaddr.js:
|
ipaddr.js:
|
||||||
specifier: 2.2.0
|
specifier: 2.2.0
|
||||||
version: 2.2.0
|
version: 2.2.0
|
||||||
|
mime:
|
||||||
|
specifier: ^4.0.4
|
||||||
|
version: 4.0.4
|
||||||
nanoid:
|
nanoid:
|
||||||
specifier: ^5.0.9
|
specifier: ^5.0.9
|
||||||
version: 5.0.9
|
version: 5.0.9
|
||||||
@ -56,8 +59,8 @@ importers:
|
|||||||
specifier: 1.0.3
|
specifier: 1.0.3
|
||||||
version: 1.0.3
|
version: 1.0.3
|
||||||
youtubei.js:
|
youtubei.js:
|
||||||
specifier: ^13.2.0
|
specifier: ^13.1.0
|
||||||
version: 13.2.0
|
version: 13.1.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.23.8
|
specifier: ^3.23.8
|
||||||
version: 3.23.8
|
version: 3.23.8
|
||||||
@ -101,8 +104,8 @@ importers:
|
|||||||
specifier: ^5.0.2
|
specifier: ^5.0.2
|
||||||
version: 5.0.2
|
version: 5.0.2
|
||||||
'@imput/libav.js-remux-cli':
|
'@imput/libav.js-remux-cli':
|
||||||
specifier: ^5.5.6
|
specifier: ^6.5.7
|
||||||
version: 5.5.6
|
version: 6.5.7
|
||||||
'@imput/version-info':
|
'@imput/version-info':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../packages/version-info
|
version: link:../packages/version-info
|
||||||
@ -188,8 +191,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
'@bufbuild/protobuf@2.2.5':
|
'@bufbuild/protobuf@2.1.0':
|
||||||
resolution: {integrity: sha512-/g5EzJifw5GF8aren8wZ/G5oMuPoGeS6MQD3ca8ddcvdXR5UELUfdTZITCGNhNXynY/AYl3Z4plmxdj/tRl/hQ==}
|
resolution: {integrity: sha512-+2Mx67Y3skJ4NCD/qNSdBJNWtu6x6Qr53jeNg+QcwiL6mt0wK+3jwHH2x1p7xaYH6Ve2JKOVn0OxU35WsmqI9A==}
|
||||||
|
|
||||||
'@datastructures-js/heap@4.3.3':
|
'@datastructures-js/heap@4.3.3':
|
||||||
resolution: {integrity: sha512-UcUu/DLh/aM4W3C8zZfwxxm6/6FIZUlm3mcAXuNOCa6Aj4iizNvNXQyb8DjZQH2jKSQbMRyNlngP6TPimuGjpQ==}
|
resolution: {integrity: sha512-UcUu/DLh/aM4W3C8zZfwxxm6/6FIZUlm3mcAXuNOCa6Aj4iizNvNXQyb8DjZQH2jKSQbMRyNlngP6TPimuGjpQ==}
|
||||||
@ -554,8 +557,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==}
|
resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
|
|
||||||
'@imput/libav.js-remux-cli@5.5.6':
|
'@imput/libav.js-remux-cli@6.5.7':
|
||||||
resolution: {integrity: sha512-XdAab90EZKf6ULtD/x9Y2bnlmNJodXSO6w8aWrn97+N2IRuOS8zv3tAFPRC69SWKa8Utjeu5YTYuTolnX3QprQ==}
|
resolution: {integrity: sha512-41ld+R5aEwllKdbiszOoCf+K614/8bnuheTZnqjgZESwDtX07xu9O8GO0m/Cpsm7O2CyqPZa1qzDx6BZVO15DQ==}
|
||||||
|
|
||||||
'@imput/psl@2.0.4':
|
'@imput/psl@2.0.4':
|
||||||
resolution: {integrity: sha512-vuy76JX78/DnJegLuJoLpMmw11JTA/9HvlIADg/f8dDVXyxbh0jnObL0q13h+WvlBO4Gk26Pu8sUa7/h0JGQig==}
|
resolution: {integrity: sha512-vuy76JX78/DnJegLuJoLpMmw11JTA/9HvlIADg/f8dDVXyxbh0jnObL0q13h+WvlBO4Gk26Pu8sUa7/h0JGQig==}
|
||||||
@ -2286,8 +2289,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
youtubei.js@13.2.0:
|
youtubei.js@13.1.0:
|
||||||
resolution: {integrity: sha512-esbSvWS12Dz/cVlHhnL/PSE84a/mVpQdzwPDIkRQu/NHJVxv0isBUcm3hJnYB1jg1LYvomV0YeOrYv5qWwJREA==}
|
resolution: {integrity: sha512-uL4TyojAYET0c5NGFD7+ScCod/k8Pc/B+D5tLrunFcz1GaBjRMOGRPcNGaRmnhwisegU7ibtw0iUxCN+BZ0ang==}
|
||||||
|
|
||||||
zod@3.23.8:
|
zod@3.23.8:
|
||||||
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
|
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
|
||||||
@ -2299,7 +2302,7 @@ snapshots:
|
|||||||
'@jridgewell/gen-mapping': 0.3.5
|
'@jridgewell/gen-mapping': 0.3.5
|
||||||
'@jridgewell/trace-mapping': 0.3.25
|
'@jridgewell/trace-mapping': 0.3.25
|
||||||
|
|
||||||
'@bufbuild/protobuf@2.2.5': {}
|
'@bufbuild/protobuf@2.1.0': {}
|
||||||
|
|
||||||
'@datastructures-js/heap@4.3.3': {}
|
'@datastructures-js/heap@4.3.3': {}
|
||||||
|
|
||||||
@ -2519,7 +2522,7 @@ snapshots:
|
|||||||
|
|
||||||
'@humanwhocodes/retry@0.4.1': {}
|
'@humanwhocodes/retry@0.4.1': {}
|
||||||
|
|
||||||
'@imput/libav.js-remux-cli@5.5.6': {}
|
'@imput/libav.js-remux-cli@6.5.7': {}
|
||||||
|
|
||||||
'@imput/psl@2.0.4':
|
'@imput/psl@2.0.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -4242,9 +4245,9 @@ snapshots:
|
|||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
youtubei.js@13.2.0:
|
youtubei.js@13.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@bufbuild/protobuf': 2.2.5
|
'@bufbuild/protobuf': 2.1.0
|
||||||
jintr: 3.2.1
|
jintr: 3.2.1
|
||||||
tslib: 2.6.3
|
tslib: 2.6.3
|
||||||
undici: 5.28.4
|
undici: 5.28.4
|
||||||
|
@ -16,5 +16,11 @@
|
|||||||
"save": "save",
|
"save": "save",
|
||||||
"export": "export",
|
"export": "export",
|
||||||
"yes": "yes",
|
"yes": "yes",
|
||||||
"no": "no"
|
"no": "no",
|
||||||
|
"clear": "clear",
|
||||||
|
"show_input": "show input",
|
||||||
|
"hide_input": "hide input",
|
||||||
|
"restore_input": "restore input",
|
||||||
|
"clear_input": "clear input",
|
||||||
|
"clear_cache": "clear cache"
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"reset.title": "reset all data?",
|
"reset_settings.title": "reset all settings?",
|
||||||
"reset.body": "are you sure you want to reset all data? this action is immediate and irreversible.",
|
"reset_settings.body": "are you sure you want to reset all settings? this action is immediate and irreversible.",
|
||||||
|
|
||||||
"picker.title": "select what to save",
|
"picker.title": "select what to save",
|
||||||
"picker.description.desktop": "click an item to save it. images can also be saved via the right click menu.",
|
"picker.description.desktop": "click an item to save it. images can also be saved via the right click menu.",
|
||||||
@ -21,5 +21,8 @@
|
|||||||
"safety.custom_instance.body": "custom instances can potentially pose privacy & safety risks.\n\nbad instances can:\n1. redirect you away from cobalt and try to scam you.\n2. log all information about your requests, store it forever, and use it to track you.\n3. serve you malicious files (such as malware).\n4. force you to watch ads, or make you pay for downloading.\n\nafter this point, we can't protect you. please be mindful of what instances to use and always trust your gut. if anything feels off, come back to this page, reset the custom instance, and report it to us on github.",
|
"safety.custom_instance.body": "custom instances can potentially pose privacy & safety risks.\n\nbad instances can:\n1. redirect you away from cobalt and try to scam you.\n2. log all information about your requests, store it forever, and use it to track you.\n3. serve you malicious files (such as malware).\n4. force you to watch ads, or make you pay for downloading.\n\nafter this point, we can't protect you. please be mindful of what instances to use and always trust your gut. if anything feels off, come back to this page, reset the custom instance, and report it to us on github.",
|
||||||
|
|
||||||
"processing.ongoing": "cobalt is currently processing media in this tab. going away will abort it. are you sure you want to do this?",
|
"processing.ongoing": "cobalt is currently processing media in this tab. going away will abort it. are you sure you want to do this?",
|
||||||
"processing.title.ongoing": "processing will be cancelled"
|
"processing.title.ongoing": "processing will be cancelled",
|
||||||
|
|
||||||
|
"clear_cache.title": "clear all cache?",
|
||||||
|
"clear_cache.body": "all files from the processing queue will be removed and on-device features will take longer to load. this action is immediate and irreversible."
|
||||||
}
|
}
|
||||||
|
@ -51,7 +51,7 @@
|
|||||||
"api.content.video.unavailable": "i can't access this video. it may be restricted on {{ service }}'s side. try a different link!",
|
"api.content.video.unavailable": "i can't access this video. it may be restricted on {{ service }}'s side. try a different link!",
|
||||||
"api.content.video.live": "this video is currently live, so i can't download it yet. wait for the live stream to finish and try again!",
|
"api.content.video.live": "this video is currently live, so i can't download it yet. wait for the live stream to finish and try again!",
|
||||||
"api.content.video.private": "this video is private, so i can't access it. change its visibility or try another one!",
|
"api.content.video.private": "this video is private, so i can't access it. change its visibility or try another one!",
|
||||||
"api.content.video.age": "this video is age-restricted, so i can't access it anonymously. try a different link!",
|
"api.content.video.age": "this video is age-restricted, so i can't access it anonymously. try again or try a different link!",
|
||||||
"api.content.video.region": "this video is region locked, and the processing instance is in a different location. try a different link!",
|
"api.content.video.region": "this video is region locked, and the processing instance is in a different location. try a different link!",
|
||||||
|
|
||||||
"api.content.region": "this content is region locked, and the processing instance is in a different location. try a different link!",
|
"api.content.region": "this content is region locked, and the processing instance is in a different location. try a different link!",
|
||||||
@ -59,15 +59,14 @@
|
|||||||
|
|
||||||
"api.content.post.unavailable": "couldn't find anything about this post. its visibility may be limited or it may not exist. make sure your link works and try again in a few seconds!",
|
"api.content.post.unavailable": "couldn't find anything about this post. its visibility may be limited or it may not exist. make sure your link works and try again in a few seconds!",
|
||||||
"api.content.post.private": "couldn't get anything about this post because it's from a private account. try a different link!",
|
"api.content.post.private": "couldn't get anything about this post because it's from a private account. try a different link!",
|
||||||
"api.content.post.age": "this post is age-restricted and isn't available without logging in. try a different link!",
|
"api.content.post.age": "this post is age-restricted, so i can't access it anonymously. try again or try a different link!",
|
||||||
|
|
||||||
"api.youtube.no_matching_format": "youtube didn't return a valid video + audio format combo, either video or audio is missing. formats for this video may be re-encoding on youtube's side or something went wrong when parsing them. try enabling the hls option in video settings!",
|
"api.youtube.no_matching_format": "youtube didn't return a valid video + audio format combo, either video or audio is missing. formats for this video may be re-encoding on youtube's side or something went wrong when parsing them. try enabling the hls option in video settings!",
|
||||||
"api.youtube.decipher": "youtube updated its decipher algorithm and i couldn't extract the info about the video. try again in a few seconds, but if this issue sticks, please report it!",
|
"api.youtube.decipher": "youtube updated its decipher algorithm and i couldn't extract the info about the video. try again in a few seconds, but if this issue sticks, please report it!",
|
||||||
"api.youtube.login": "couldn't get this video because youtube asked the instance to log in. this is potentially caused by the processing instance not having any active account tokens or youtube updating something about their api. try again in a few seconds, but if it still doesn't work, please report this issue!",
|
"api.youtube.login": "couldn't get this video because youtube told the processing instance that it's a bot and restricted its access. try again in a few seconds, but if it still doesn't work, please report this issue!",
|
||||||
"api.youtube.token_expired": "couldn't get this video because the youtube token expired and i couldn't refresh it. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!",
|
"api.youtube.token_expired": "couldn't get this video because the youtube token expired and i couldn't refresh it. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!",
|
||||||
"api.youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!",
|
"api.youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!",
|
||||||
"api.youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!",
|
"api.youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!",
|
||||||
"api.youtube.temporary_disabled": "youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\n\nwe apologize for the inconvenience and are doing our best to restore this functionality. check cobalt's socials or github for timely updates!",
|
"api.youtube.temporary_disabled": "youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\n\nwe apologize for the inconvenience and are doing our best to restore this functionality. check cobalt's socials or github for timely updates!",
|
||||||
"api.youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!",
|
"api.youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!"
|
||||||
"api.youtube.no_session_tokens": "couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!"
|
|
||||||
}
|
}
|
||||||
|
17
web/i18n/en/queue.json
Normal file
17
web/i18n/en/queue.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"title": "processing queue",
|
||||||
|
"stub": "nothing here yet, just the two of us.\ntry {{ value }} something!",
|
||||||
|
"stub.download": "downloading",
|
||||||
|
"stub.remux": "remuxing",
|
||||||
|
|
||||||
|
"state.waiting": "queued",
|
||||||
|
"state.retrying": "retrying",
|
||||||
|
|
||||||
|
"state.starting": "starting",
|
||||||
|
"state.starting.fetch": "starting downloading",
|
||||||
|
"state.starting.remux": "starting muxing",
|
||||||
|
|
||||||
|
"state.running.remux": "muxing",
|
||||||
|
"state.running.fetch": "downloading",
|
||||||
|
"estimated_storage_usage": "estimated storage usage:"
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "drag or select a file",
|
"title": "drag or select a file",
|
||||||
|
"title.multiple": "drag or select files",
|
||||||
"title.drop": "drop the file here!",
|
"title.drop": "drop the file here!",
|
||||||
|
"title.drop.multiple": "drop the files here!",
|
||||||
"accept": "supported formats: {{ formats }}."
|
"accept": "supported formats: {{ formats }}."
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,8 @@
|
|||||||
"page.advanced": "advanced",
|
"page.advanced": "advanced",
|
||||||
"page.debug": "info for nerds",
|
"page.debug": "info for nerds",
|
||||||
"page.instances": "instances",
|
"page.instances": "instances",
|
||||||
|
"page.local": "local processing",
|
||||||
|
"page.accessibility": "accessibility",
|
||||||
|
|
||||||
"section.general": "general",
|
"section.general": "general",
|
||||||
"section.save": "save",
|
"section.save": "save",
|
||||||
@ -72,7 +74,7 @@
|
|||||||
"metadata.filename.nerdy": "nerdy",
|
"metadata.filename.nerdy": "nerdy",
|
||||||
"metadata.filename.description": "filename style will only be used for files tunneled by cobalt. some services don't support filename styles other than classic.",
|
"metadata.filename.description": "filename style will only be used for files tunneled by cobalt. some services don't support filename styles other than classic.",
|
||||||
|
|
||||||
"metadata.filename.preview.video": "Video Title",
|
"metadata.filename.preview.video": "Video Title - Video Author",
|
||||||
"metadata.filename.preview.audio": "Audio Title - Audio Author",
|
"metadata.filename.preview.audio": "Audio Title - Audio Author",
|
||||||
|
|
||||||
"metadata.file": "file metadata",
|
"metadata.file": "file metadata",
|
||||||
@ -86,11 +88,18 @@
|
|||||||
"saving.copy": "copy",
|
"saving.copy": "copy",
|
||||||
"saving.description": "preferred way of saving the file or link from cobalt. if preferred method is unavailable or something goes wrong, cobalt will ask you what to do next.",
|
"saving.description": "preferred way of saving the file or link from cobalt. if preferred method is unavailable or something goes wrong, cobalt will ask you what to do next.",
|
||||||
|
|
||||||
"accessibility": "accessibility",
|
"accessibility.visual": "visual",
|
||||||
|
"accessibility.haptics": "haptics",
|
||||||
|
"accessibility.behavior": "behavior",
|
||||||
|
|
||||||
"accessibility.transparency.title": "reduce visual transparency",
|
"accessibility.transparency.title": "reduce visual transparency",
|
||||||
"accessibility.transparency.description": "reduces transparency of surfaces and disables blur effects. may also improve ui performance on low performance devices.",
|
"accessibility.transparency.description": "transparency of surfaces will be reduced and all blur effects will be disabled. may also improve ui performance on less powerful devices.",
|
||||||
"accessibility.motion.title": "reduce motion",
|
"accessibility.motion.title": "reduce motion",
|
||||||
"accessibility.motion.description": "disables animations and transitions whenever possible.",
|
"accessibility.motion.description": "animations and transitions will be disabled whenever possible.",
|
||||||
|
"accessibility.haptics.title": "disable haptics",
|
||||||
|
"accessibility.haptics.description": "all haptic effects will be disabled.",
|
||||||
|
"accessibility.auto_queue.title": "don't open the queue automatically",
|
||||||
|
"accessibility.auto_queue.description": "the processing queue will not be opened automatically whenever a new item is added to it. progress will still be displayed and you will still be able to open it manually.",
|
||||||
|
|
||||||
"language": "language",
|
"language": "language",
|
||||||
"language.auto.title": "automatic selection",
|
"language.auto.title": "automatic selection",
|
||||||
@ -111,8 +120,6 @@
|
|||||||
"advanced.debug.title": "enable features for nerds",
|
"advanced.debug.title": "enable features for nerds",
|
||||||
"advanced.debug.description": "gives you easy access to app info that can be useful for debugging. enabling this does not affect functionality of cobalt in any way.",
|
"advanced.debug.description": "gives you easy access to app info that can be useful for debugging. enabling this does not affect functionality of cobalt in any way.",
|
||||||
|
|
||||||
"advanced.data": "data management",
|
|
||||||
|
|
||||||
"processing.community": "community instances",
|
"processing.community": "community instances",
|
||||||
"processing.enable_custom.title": "use a custom processing server",
|
"processing.enable_custom.title": "use a custom processing server",
|
||||||
"processing.enable_custom.description": "cobalt will use a custom processing instance if you choose to. even though cobalt has some security measures in place, we are not responsible for any damages done via a community instance, as we have no control over them.\n\nplease be mindful of what instances you use and make sure they're hosted by people you trust.",
|
"processing.enable_custom.description": "cobalt will use a custom processing instance if you choose to. even though cobalt has some security measures in place, we are not responsible for any damages done via a community instance, as we have no control over them.\n\nplease be mindful of what instances you use and make sure they're hosted by people you trust.",
|
||||||
@ -122,5 +129,16 @@
|
|||||||
"processing.access_key.description": "cobalt will use this key to make requests to the processing instance instead of other authentication methods. make sure the instance supports api keys!",
|
"processing.access_key.description": "cobalt will use this key to make requests to the processing instance instead of other authentication methods. make sure the instance supports api keys!",
|
||||||
|
|
||||||
"processing.custom_instance.input.alt_text": "custom instance domain",
|
"processing.custom_instance.input.alt_text": "custom instance domain",
|
||||||
"processing.access_key.input.alt_text": "u-u-i-d access key"
|
"processing.access_key.input.alt_text": "u-u-i-d access key",
|
||||||
|
|
||||||
|
"advanced.settings_data": "settings data",
|
||||||
|
"advanced.local_storage": "local storage",
|
||||||
|
|
||||||
|
"local.saving": "media processing",
|
||||||
|
"local.saving.title": "mux and convert media on device",
|
||||||
|
"local.saving.description": "when downloading media, cobalt will do needed processing on-device instead of using cloud compute. files will download faster and more reliably.\n\nexclusive local features are not affected by this toggle, they always run locally.",
|
||||||
|
|
||||||
|
"local.webcodecs": "webcodecs",
|
||||||
|
"local.webcodecs.title": "use webcodecs for on-device processing",
|
||||||
|
"local.webcodecs.description": "when decoding or encoding files, cobalt will try to use webcodecs. this feature allows for GPU-accelerated processing of media files, meaning that all decoding & encoding will be way faster.\n\navailability and stability of this feature depends on your device's and browser's capabilities. stuff might break or not work properly."
|
||||||
}
|
}
|
||||||
|
@ -28,7 +28,7 @@
|
|||||||
"@fontsource-variable/noto-sans-mono": "^5.0.20",
|
"@fontsource-variable/noto-sans-mono": "^5.0.20",
|
||||||
"@fontsource/ibm-plex-mono": "^5.0.13",
|
"@fontsource/ibm-plex-mono": "^5.0.13",
|
||||||
"@fontsource/redaction-10": "^5.0.2",
|
"@fontsource/redaction-10": "^5.0.2",
|
||||||
"@imput/libav.js-remux-cli": "^5.5.6",
|
"@imput/libav.js-remux-cli": "^6.5.7",
|
||||||
"@imput/version-info": "workspace:^",
|
"@imput/version-info": "workspace:^",
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.9.1",
|
"@sveltejs/kit": "^2.9.1",
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png">
|
<link rel="icon" href="%sveltekit.assets%/favicon.png">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/icons/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/icons/apple-touch-icon.png">
|
||||||
<link type="application/activity+json" href="" />
|
<link type="application/activity+json" href="">
|
||||||
|
|
||||||
<link crossorigin="use-credentials" rel="manifest" href="%sveltekit.assets%/manifest.json">
|
<link crossorigin="use-credentials" rel="manifest" href="%sveltekit.assets%/manifest.json">
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="support-card"
|
class="button support-card"
|
||||||
role="link"
|
role="link"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
openURL(externalLink);
|
openURL(externalLink);
|
||||||
@ -68,7 +68,6 @@
|
|||||||
.support-card {
|
.support-card {
|
||||||
padding: var(--padding);
|
padding: var(--padding);
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
height: fit-content;
|
|
||||||
|
|
||||||
text-align: start;
|
text-align: start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -6,6 +6,8 @@
|
|||||||
Value extends CobaltSettings[Context][Id]
|
Value extends CobaltSettings[Context][Id]
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
import { hapticSwitch } from "$lib/haptics";
|
||||||
|
|
||||||
import settings, { updateSetting } from "$lib/state/settings";
|
import settings, { updateSetting } from "$lib/state/settings";
|
||||||
import type { CobaltSettings } from "$lib/types/settings";
|
import type { CobaltSettings } from "$lib/types/settings";
|
||||||
|
|
||||||
@ -22,12 +24,14 @@
|
|||||||
class="button"
|
class="button"
|
||||||
class:active={isActive}
|
class:active={isActive}
|
||||||
aria-pressed={isActive}
|
aria-pressed={isActive}
|
||||||
on:click={() =>
|
on:click={() => {
|
||||||
|
hapticSwitch();
|
||||||
updateSetting({
|
updateSetting({
|
||||||
[settingContext]: {
|
[settingContext]: {
|
||||||
[settingId]: settingValue,
|
[settingId]: settingValue,
|
||||||
},
|
},
|
||||||
})}
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</button>
|
</button>
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
Id extends keyof CobaltSettings[Context]
|
Id extends keyof CobaltSettings[Context]
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
import { hapticSwitch } from "$lib/haptics";
|
||||||
import settings, { updateSetting } from "$lib/state/settings";
|
import settings, { updateSetting } from "$lib/state/settings";
|
||||||
import type { CobaltSettings } from "$lib/types/settings";
|
import type { CobaltSettings } from "$lib/types/settings";
|
||||||
|
|
||||||
@ -31,17 +32,18 @@
|
|||||||
aria-hidden={disabled}
|
aria-hidden={disabled}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="toggle-container"
|
class="button toggle-container"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={isEnabled}
|
aria-checked={isEnabled}
|
||||||
disabled={disabled}
|
{disabled}
|
||||||
on:click={() =>
|
on:click={() => {
|
||||||
|
hapticSwitch();
|
||||||
updateSetting({
|
updateSetting({
|
||||||
[settingContext]: {
|
[settingContext]: {
|
||||||
[settingId]: !isEnabled,
|
[settingId]: !isEnabled,
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<h4 class="toggle-title">{title}</h4>
|
<h4 class="toggle-title">{title}</h4>
|
||||||
<Toggle enabled={isEnabled} />
|
<Toggle enabled={isEnabled} />
|
||||||
@ -81,5 +83,12 @@
|
|||||||
padding: calc(var(--switcher-padding) * 2) 16px;
|
padding: calc(var(--switcher-padding) * 2) 16px;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
overflow: scroll;
|
overflow: scroll;
|
||||||
|
transition: box-shadow 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-container:active {
|
||||||
|
box-shadow:
|
||||||
|
var(--button-box-shadow),
|
||||||
|
0 0 0 1.5px var(--button-stroke) inset;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -75,7 +75,7 @@
|
|||||||
.switcher.big :global(.button) {
|
.switcher.big :global(.button) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
/* [base button height] - ([switcher padding] * [padding factor to accommodate for]) */
|
/* [base button height] - ([switcher padding] * [padding factor to accommodate for]) */
|
||||||
height: calc(40px - var(--switcher-padding) * 1.5);
|
height: calc(40px - var(--switcher-padding) * 2);
|
||||||
border-radius: calc(var(--border-radius) - var(--switcher-padding));;
|
border-radius: calc(var(--border-radius) - var(--switcher-padding));;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,12 +87,16 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switcher.big :global(.button:active:not(.active)) {
|
||||||
|
box-shadow: var(--button-box-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
.switcher:not(.big) :global(.button:not(:first-child, :last-child)) {
|
.switcher:not(.big) :global(.button:not(:first-child, :last-child)) {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* hack to get rid of double border in a list of switches */
|
/* hack to get rid of double border in a list of switches */
|
||||||
.switcher:not(.big) :global(:not(.button:first-child)) {
|
.switcher:not(.big) :global(:not(.button:first-child)) {
|
||||||
margin-left: -1.5px;
|
margin-left: -1px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -16,9 +16,14 @@
|
|||||||
if (dialogParent) {
|
if (dialogParent) {
|
||||||
closing = true;
|
closing = true;
|
||||||
open = false;
|
open = false;
|
||||||
|
|
||||||
|
// wait 150ms for the closing animation to finish
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
dialogParent.close();
|
// check if dialog parent is still present
|
||||||
killDialog();
|
if (dialogParent) {
|
||||||
|
dialogParent.close();
|
||||||
|
killDialog();
|
||||||
|
}
|
||||||
}, 150);
|
}, 150);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -136,7 +136,11 @@
|
|||||||
|
|
||||||
:global(dialog .dialog-body) {
|
:global(dialog .dialog-body) {
|
||||||
margin-bottom: calc(
|
margin-bottom: calc(
|
||||||
var(--padding) / 2 + env(safe-area-inset-bottom)
|
var(--padding) + calc(
|
||||||
|
env(safe-area-inset-bottom) - 15px * sign(
|
||||||
|
env(safe-area-inset-bottom)
|
||||||
|
)
|
||||||
|
)
|
||||||
) !important;
|
) !important;
|
||||||
box-shadow: 0 0 0 2px var(--popup-stroke) inset;
|
box-shadow: 0 0 0 2px var(--popup-stroke) inset;
|
||||||
}
|
}
|
||||||
|
@ -65,6 +65,9 @@
|
|||||||
<style>
|
<style>
|
||||||
.picker-dialog {
|
.picker-dialog {
|
||||||
--picker-item-size: 120px;
|
--picker-item-size: 120px;
|
||||||
|
--picker-item-gap: 4px;
|
||||||
|
--picker-item-area: calc(var(--picker-item-size) + var(--picker-item-gap));
|
||||||
|
|
||||||
gap: var(--padding);
|
gap: var(--padding);
|
||||||
max-height: calc(
|
max-height: calc(
|
||||||
90% - env(safe-area-inset-bottom) - env(safe-area-inset-top)
|
90% - env(safe-area-inset-bottom) - env(safe-area-inset-top)
|
||||||
@ -77,7 +80,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
max-width: calc(var(--picker-item-size) * 4);
|
max-width: calc(var(--picker-item-area) * 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-title-container {
|
.popup-title-container {
|
||||||
@ -112,6 +115,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||||
|
gap: var(--picker-item-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
.three-columns .picker-body {
|
.three-columns .picker-body {
|
||||||
@ -119,7 +123,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.three-columns .popup-header {
|
.three-columns .popup-header {
|
||||||
max-width: calc(var(--picker-item-size) * 3);
|
max-width: calc(var(--picker-item-area) * 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.picker-item) {
|
:global(.picker-item) {
|
||||||
@ -133,48 +137,78 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.popup-header {
|
.popup-header {
|
||||||
max-width: calc(var(--picker-item-size) * 3);
|
max-width: calc(var(--picker-item-area) * 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 400px) {
|
@media screen and (max-width: 410px) {
|
||||||
|
.picker-dialog {
|
||||||
|
--picker-item-size: 118px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 405px) {
|
||||||
|
.picker-dialog {
|
||||||
|
--picker-item-size: 116px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 398px) {
|
||||||
.picker-dialog {
|
.picker-dialog {
|
||||||
--picker-item-size: 115px;
|
--picker-item-size: 115px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 380px) {
|
@media screen and (max-width: 388px) {
|
||||||
.picker-dialog {
|
.picker-dialog {
|
||||||
--picker-item-size: 110px;
|
--picker-item-size: 110px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 365px) {
|
@media screen and (max-width: 378px) {
|
||||||
.picker-dialog {
|
.picker-dialog {
|
||||||
--picker-item-size: 105px;
|
--picker-item-size: 105px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 350px) {
|
@media screen and (max-width: 365px) {
|
||||||
.picker-dialog {
|
.picker-dialog {
|
||||||
--picker-item-size: 100px;
|
--picker-item-size: 100px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 335px) {
|
@media screen and (max-width: 352px) {
|
||||||
|
.picker-dialog {
|
||||||
|
--picker-item-size: 95px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 334px) {
|
||||||
|
.picker-dialog {
|
||||||
|
--picker-item-size: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
.picker-body,
|
.picker-body,
|
||||||
.three-columns .picker-body {
|
.three-columns .picker-body {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.popup-header {
|
@media screen and (max-width: 300px) {
|
||||||
max-width: calc(var(--picker-item-size) * 3);
|
.picker-dialog {
|
||||||
|
--picker-item-size: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 280px) {
|
||||||
|
.picker-dialog {
|
||||||
|
--picker-item-size: 110px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 255px) {
|
@media screen and (max-width: 255px) {
|
||||||
.picker-dialog {
|
.picker-dialog {
|
||||||
--picker-item-size: 120px;
|
--picker-item-size: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-body,
|
.picker-body,
|
||||||
|
@ -62,11 +62,20 @@
|
|||||||
.picker-item {
|
.picker-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: none;
|
background: none;
|
||||||
padding: 2px;
|
padding: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
border-radius: calc(var(--border-radius) / 2 + 2px);
|
border-radius: calc(var(--border-radius) / 2 + 2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.picker-item:focus-visible::after {
|
||||||
|
content: "";
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
:global(.picker-image) {
|
:global(.picker-image) {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -76,7 +85,7 @@
|
|||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
|
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: calc(var(--border-radius) / 2);
|
border-radius: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-image.loading {
|
.picker-image.loading {
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import { t } from "$lib/i18n/translations";
|
import { t } from "$lib/i18n/translations";
|
||||||
|
|
||||||
import { device } from "$lib/device";
|
import { device } from "$lib/device";
|
||||||
|
import { hapticConfirm } from "$lib/haptics";
|
||||||
import {
|
import {
|
||||||
copyURL,
|
copyURL,
|
||||||
openURL,
|
openURL,
|
||||||
@ -101,8 +102,11 @@
|
|||||||
fill
|
fill
|
||||||
elevated
|
elevated
|
||||||
click={async () => {
|
click={async () => {
|
||||||
copyURL(url);
|
if (!copied) {
|
||||||
copied = true;
|
copyURL(url);
|
||||||
|
hapticConfirm();
|
||||||
|
copied = true;
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
ariaLabel={copied ? $t("button.copied") : ""}
|
ariaLabel={copied ? $t("button.copied") : ""}
|
||||||
>
|
>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { hapticError } from "$lib/haptics";
|
||||||
import type { Optional } from "$lib/types/generic";
|
import type { Optional } from "$lib/types/generic";
|
||||||
import type { MeowbaltEmotions } from "$lib/types/meowbalt";
|
import type { MeowbaltEmotions } from "$lib/types/meowbalt";
|
||||||
import type { DialogButton, SmallDialogIcons } from "$lib/types/dialog";
|
import type { DialogButton, SmallDialogIcons } from "$lib/types/dialog";
|
||||||
@ -21,6 +22,11 @@
|
|||||||
export let leftAligned = false;
|
export let leftAligned = false;
|
||||||
|
|
||||||
let close: () => void;
|
let close: () => void;
|
||||||
|
|
||||||
|
// error meowbalt art is not used in dialogs unless it's an error
|
||||||
|
if (meowbalt === "error") {
|
||||||
|
hapticError();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DialogContainer {id} {dismissable} bind:close>
|
<DialogContainer {id} {dismissable} bind:close>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from "$lib/i18n/translations";
|
import { t } from "$lib/i18n/translations";
|
||||||
|
import { hapticConfirm } from "$lib/haptics";
|
||||||
import { copyURL, openURL } from "$lib/download";
|
import { copyURL, openURL } from "$lib/download";
|
||||||
|
|
||||||
import CopyIcon from "$components/misc/CopyIcon.svelte";
|
import CopyIcon from "$components/misc/CopyIcon.svelte";
|
||||||
@ -21,14 +22,17 @@
|
|||||||
|
|
||||||
<div class="wallet-holder">
|
<div class="wallet-holder">
|
||||||
<button
|
<button
|
||||||
class="wallet"
|
class="button wallet"
|
||||||
aria-label={$t(`donate.alt.${type}`, {
|
aria-label={$t(`donate.alt.${type}`, {
|
||||||
value: name,
|
value: name,
|
||||||
})}
|
})}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
if (type === "copy") {
|
if (type === "copy") {
|
||||||
copied = true;
|
if (!copied) {
|
||||||
copyURL(address);
|
copyURL(address);
|
||||||
|
hapticConfirm();
|
||||||
|
copied = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
openURL(address);
|
openURL(address);
|
||||||
}
|
}
|
||||||
@ -88,7 +92,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-right: 1.5px var(--button-stroke) solid;
|
border-right: 1px var(--button-stroke) solid;
|
||||||
margin-left: 3px;
|
margin-left: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,18 +47,19 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.donate-card button:active) {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
:global(.donate-card button:hover) {
|
:global(.donate-card button:hover:not(.selected):not(.scroll-button)) {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.donate-card button:active:not(.selected):not(.scroll-button)) {
|
||||||
|
background: rgba(255, 255, 255, 0.125);
|
||||||
|
}
|
||||||
|
|
||||||
:global(.donate-card button.selected) {
|
:global(.donate-card button.selected) {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.donate-card button.selected:not(:focus-visible)) {
|
:global(.donate-card button.selected:not(:focus-visible)) {
|
||||||
|
@ -75,7 +75,7 @@
|
|||||||
return window.open(donationMethods[processor](amount), "_blank");
|
return window.open(donationMethods[processor](amount), "_blank");
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollBehavior = $settings.appearance.reduceMotion
|
const scrollBehavior = $settings.accessibility.reduceMotion
|
||||||
? "instant"
|
? "instant"
|
||||||
: "smooth";
|
: "smooth";
|
||||||
|
|
||||||
@ -85,7 +85,7 @@
|
|||||||
const scroll = (direction: "left" | "right") => {
|
const scroll = (direction: "left" | "right") => {
|
||||||
const currentPos = donateList.scrollLeft;
|
const currentPos = donateList.scrollLeft;
|
||||||
const maxPos = donateList.scrollWidth - donateList.getBoundingClientRect().width;
|
const maxPos = donateList.scrollWidth - donateList.getBoundingClientRect().width;
|
||||||
const newPos = direction === "left" ? currentPos - 150 : currentPos + 150;
|
const newPos = direction === "left" ? currentPos - 250 : currentPos + 250;
|
||||||
|
|
||||||
donateList.scroll({
|
donateList.scroll({
|
||||||
left: newPos,
|
left: newPos,
|
||||||
@ -285,10 +285,17 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
#input-container:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#input-dollar-sign {
|
#input-dollar-sign {
|
||||||
@ -336,7 +343,6 @@
|
|||||||
|
|
||||||
#donation-custom-submit {
|
#donation-custom-submit {
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
aspect-ratio: 1/1;
|
aspect-ratio: 1/1;
|
||||||
padding: 0px 10px;
|
padding: 0px 10px;
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import { device } from "$lib/device";
|
import { device } from "$lib/device";
|
||||||
import locale from "$lib/i18n/locale";
|
import locale from "$lib/i18n/locale";
|
||||||
import { t } from "$lib/i18n/translations";
|
import { t } from "$lib/i18n/translations";
|
||||||
|
import { hapticConfirm } from "$lib/haptics";
|
||||||
|
|
||||||
import { openURL, copyURL, shareURL } from "$lib/download";
|
import { openURL, copyURL, shareURL } from "$lib/download";
|
||||||
|
|
||||||
@ -51,8 +52,11 @@
|
|||||||
id="action-button-copy"
|
id="action-button-copy"
|
||||||
class="action-button"
|
class="action-button"
|
||||||
on:click={async () => {
|
on:click={async () => {
|
||||||
copyURL(cobaltUrl);
|
if (!copied) {
|
||||||
copied = true;
|
copyURL(cobaltUrl);
|
||||||
|
hapticConfirm();
|
||||||
|
copied = true;
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
aria-label={copied ? $t("button.copied") : ""}
|
aria-label={copied ? $t("button.copied") : ""}
|
||||||
>
|
>
|
||||||
@ -176,7 +180,7 @@
|
|||||||
.action-button {
|
.action-button {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 10px;
|
padding: 0 6px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
@ -3,15 +3,15 @@
|
|||||||
export let classes = "";
|
export let classes = "";
|
||||||
|
|
||||||
export let draggedOver = false;
|
export let draggedOver = false;
|
||||||
export let file: File | undefined;
|
export let files: FileList | undefined;
|
||||||
|
|
||||||
const dropHandler = async (ev: DragEvent) => {
|
const dropHandler = async (ev: DragEvent) => {
|
||||||
draggedOver = false;
|
draggedOver = false;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|
||||||
if (ev?.dataTransfer?.files.length === 1) {
|
if (ev?.dataTransfer?.files && ev?.dataTransfer?.files.length > 0) {
|
||||||
file = ev.dataTransfer.files[0];
|
files = ev.dataTransfer.files;
|
||||||
return file;
|
return files;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -25,6 +25,7 @@
|
|||||||
{id}
|
{id}
|
||||||
class={classes}
|
class={classes}
|
||||||
role="region"
|
role="region"
|
||||||
|
aria-hidden="true"
|
||||||
on:drop={(ev) => dropHandler(ev)}
|
on:drop={(ev) => dropHandler(ev)}
|
||||||
on:dragover={(ev) => dragOverHandler(ev)}
|
on:dragover={(ev) => dragOverHandler(ev)}
|
||||||
on:dragend={() => {
|
on:dragend={() => {
|
||||||
|
@ -5,22 +5,33 @@
|
|||||||
import IconFileImport from "@tabler/icons-svelte/IconFileImport.svelte";
|
import IconFileImport from "@tabler/icons-svelte/IconFileImport.svelte";
|
||||||
import IconUpload from "@tabler/icons-svelte/IconUpload.svelte";
|
import IconUpload from "@tabler/icons-svelte/IconUpload.svelte";
|
||||||
|
|
||||||
export let file: File | undefined;
|
export let files: FileList | undefined;
|
||||||
export let draggedOver = false;
|
export let draggedOver = false;
|
||||||
export let acceptTypes: string[];
|
export let acceptTypes: string[];
|
||||||
export let acceptExtensions: string[];
|
export let acceptExtensions: string[];
|
||||||
|
export let maxFileNumber: number = 100;
|
||||||
|
|
||||||
|
let selectorStringMultiple = maxFileNumber > 1 ? ".multiple" : "";
|
||||||
|
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
const openFile = async () => {
|
const openFile = async () => {
|
||||||
fileInput = document.createElement("input");
|
fileInput = document.createElement("input");
|
||||||
fileInput.type = "file";
|
fileInput.type = "file";
|
||||||
fileInput.accept = acceptTypes.join(",");
|
fileInput.accept = acceptTypes.join(",");
|
||||||
|
|
||||||
|
if (maxFileNumber > 1) {
|
||||||
|
fileInput.multiple = true;
|
||||||
|
}
|
||||||
|
|
||||||
fileInput.click();
|
fileInput.click();
|
||||||
fileInput.onchange = async () => {
|
fileInput.onchange = async () => {
|
||||||
if (fileInput.files?.length === 1) {
|
let userFiles = fileInput?.files;
|
||||||
file = fileInput.files[0];
|
if (userFiles && userFiles.length >= 1) {
|
||||||
return file;
|
if (userFiles.length > maxFileNumber) {
|
||||||
|
return alert("too many files, limit is " + maxFileNumber);
|
||||||
|
}
|
||||||
|
return files = userFiles;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -29,7 +40,7 @@
|
|||||||
<div class="open-file-container" class:dragged-over={draggedOver}>
|
<div class="open-file-container" class:dragged-over={draggedOver}>
|
||||||
<Meowbalt emotion="question" />
|
<Meowbalt emotion="question" />
|
||||||
|
|
||||||
<button class="open-file-button" on:click={openFile}>
|
<button class="button open-file-button" on:click={openFile}>
|
||||||
<div class="dashed-stroke">
|
<div class="dashed-stroke">
|
||||||
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect width="100%" height="100%" fill="none" rx="24" ry="24" />
|
<rect width="100%" height="100%" fill="none" rx="24" ry="24" />
|
||||||
@ -47,9 +58,9 @@
|
|||||||
<div class="open-file-text">
|
<div class="open-file-text">
|
||||||
<div class="open-title">
|
<div class="open-title">
|
||||||
{#if draggedOver}
|
{#if draggedOver}
|
||||||
{$t("receiver.title.drop")}
|
{$t("receiver.title.drop" + selectorStringMultiple)}
|
||||||
{:else}
|
{:else}
|
||||||
{$t("receiver.title")}
|
{$t("receiver.title" + selectorStringMultiple)}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="subtext accept-list">
|
<div class="subtext accept-list">
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from "$lib/i18n/translations";
|
import { t } from "$lib/i18n/translations";
|
||||||
|
|
||||||
import type { MeowbaltEmotions } from "$lib/types/meowbalt";
|
import type { MeowbaltEmotions } from "$lib/types/meowbalt";
|
||||||
|
|
||||||
export let emotion: MeowbaltEmotions;
|
export let emotion: MeowbaltEmotions;
|
||||||
|
72
web/src/components/misc/PopoverContainer.svelte
Normal file
72
web/src/components/misc/PopoverContainer.svelte
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let id = "";
|
||||||
|
export let expanded = false;
|
||||||
|
export let expandStart: "left" | "center" | "right" = "center";
|
||||||
|
|
||||||
|
/*
|
||||||
|
a popover isn't pre-rendered by default, because the user might never open it.
|
||||||
|
but if they do, we render only once, and then keep it the dom :3
|
||||||
|
*/
|
||||||
|
|
||||||
|
$: renderPopover = false;
|
||||||
|
$: if (expanded && !renderPopover) renderPopover = true;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {id} class="popover {expandStart}" aria-hidden={!expanded} class:expanded>
|
||||||
|
{#if renderPopover}
|
||||||
|
<slot></slot>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.popover {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--button);
|
||||||
|
box-shadow:
|
||||||
|
var(--button-box-shadow),
|
||||||
|
0 0 10px 10px var(--popover-glow);
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
padding: var(--padding);
|
||||||
|
gap: 6px;
|
||||||
|
top: 6px;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0);
|
||||||
|
transform-origin: top center;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
transform 0.3s cubic-bezier(0.53, 0.05, 0.23, 1.15),
|
||||||
|
opacity 0.25s cubic-bezier(0.53, 0.05, 0.23, 0.99);
|
||||||
|
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover.left {
|
||||||
|
transform-origin: top left;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([dir="rtl"]) .popover.left {
|
||||||
|
transform-origin: top right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover.center {
|
||||||
|
transform-origin: top center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover.right {
|
||||||
|
transform-origin: top right;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([dir="rtl"]) .popover.right {
|
||||||
|
transform-origin: top left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover.expanded {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,13 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { t } from "$lib/i18n/translations";
|
|
||||||
import { copyURL } from "$lib/download";
|
import { copyURL } from "$lib/download";
|
||||||
|
import { t } from "$lib/i18n/translations";
|
||||||
|
import { hapticConfirm } from "$lib/haptics";
|
||||||
|
|
||||||
import CopyIcon from "$components/misc/CopyIcon.svelte";
|
import CopyIcon from "$components/misc/CopyIcon.svelte";
|
||||||
|
|
||||||
export let title: string;
|
export let title: string;
|
||||||
export let sectionId: string;
|
export let sectionId: string;
|
||||||
export let beta = false;
|
export let beta = false;
|
||||||
|
export let nolink = false;
|
||||||
export let copyData = "";
|
export let copyData = "";
|
||||||
|
|
||||||
const sectionURL = `${$page.url.origin}${$page.url.pathname}#${sectionId}`;
|
const sectionURL = `${$page.url.origin}${$page.url.pathname}#${sectionId}`;
|
||||||
@ -32,18 +34,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
{#if !nolink}
|
||||||
class="link-copy"
|
<button
|
||||||
aria-label={copied
|
class="link-copy"
|
||||||
? $t("button.copied")
|
aria-label={copied
|
||||||
: $t(`button.copy${copyData ? "" : ".section"}`)}
|
? $t("button.copied")
|
||||||
on:click={() => {
|
: $t(`button.copy${copyData ? "" : ".section"}`)}
|
||||||
copied = true;
|
on:click={() => {
|
||||||
copyURL(copyData || sectionURL);
|
if (!copied) {
|
||||||
}}
|
copyURL(copyData || sectionURL);
|
||||||
>
|
hapticConfirm();
|
||||||
<CopyIcon check={copied} regularIcon={!!copyData} />
|
copied = true;
|
||||||
</button>
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CopyIcon check={copied} regularIcon={!!copyData} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -90,7 +97,7 @@
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.9;
|
line-height: 1.86;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
background: var(--toggle-bg);
|
background: var(--toggle-bg);
|
||||||
transition: background 0.2s;
|
transition: background 0.25s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle:dir(rtl) {
|
.toggle:dir(rtl) {
|
||||||
@ -34,7 +34,7 @@
|
|||||||
background: var(--white);
|
background: var(--white);
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
transform: translateX(0%);
|
transform: translateX(0%);
|
||||||
transition: transform 0.2s, width 0.2s;
|
transition: transform 0.25s cubic-bezier(0.53, 0.05, 0.02, 1.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle.enabled {
|
.toggle.enabled {
|
||||||
@ -44,8 +44,4 @@
|
|||||||
.toggle.enabled .toggle-switcher {
|
.toggle.enabled .toggle-switcher {
|
||||||
transform: translateX(var(--enabled-pos));
|
transform: translateX(var(--enabled-pos));
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.toggle-container:active .toggle:not(.enabled) .toggle-switcher) {
|
|
||||||
width: calc(var(--base-size) * 1.3);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,11 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from "$lib/i18n/translations";
|
import { t } from "$lib/i18n/translations";
|
||||||
|
|
||||||
import IconComet from "@tabler/icons-svelte/IconComet.svelte";
|
import IconComet from "@tabler/icons-svelte/IconComet.svelte";
|
||||||
|
|
||||||
|
let dismissed = false;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="update-notification" role="alert" aria-atomic="true">
|
<div id="update-notification" role="alert" aria-atomic="true">
|
||||||
<button class="update-button" on:click={() => window.location.reload()}>
|
<button
|
||||||
|
class="button update-button"
|
||||||
|
class:visible={!dismissed}
|
||||||
|
on:click={() => {
|
||||||
|
dismissed = true;
|
||||||
|
window.location.reload()
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="update-icon">
|
<div class="update-icon">
|
||||||
<IconComet />
|
<IconComet />
|
||||||
</div>
|
</div>
|
||||||
@ -32,12 +40,19 @@
|
|||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin: var(--padding);
|
margin: var(--padding);
|
||||||
|
margin-right: 71px;
|
||||||
margin-top: calc(env(safe-area-inset-top) + var(--padding));
|
margin-top: calc(env(safe-area-inset-top) + var(--padding));
|
||||||
box-shadow:
|
box-shadow:
|
||||||
var(--button-box-shadow),
|
var(--button-box-shadow),
|
||||||
0 0 10px 0px var(--button-elevated-hover);
|
0 0 10px 0px var(--button-elevated-hover);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
animation: slide-in-top 0.4s;
|
|
||||||
|
transform: translateY(-150px);
|
||||||
|
transition: transform 0.4s cubic-bezier(0.53, 0.05, 0.23, 1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-button.visible {
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-icon {
|
.update-icon {
|
||||||
@ -74,29 +89,15 @@
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-in-top {
|
|
||||||
from {
|
|
||||||
transform: translateY(-150px);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 535px) {
|
@media screen and (max-width: 535px) {
|
||||||
#update-notification {
|
#update-notification {
|
||||||
bottom: var(--sidebar-height-mobile);
|
bottom: calc(var(--sidebar-height-mobile) + 5px);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
animation: slide-in-bottom 0.4s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-in-bottom {
|
.update-button {
|
||||||
from {
|
transform: translateY(300px);
|
||||||
transform: translateY(300px);
|
margin-right: var(--padding);
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
212
web/src/components/queue/ProcessingQueue.svelte
Normal file
212
web/src/components/queue/ProcessingQueue.svelte
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n/translations";
|
||||||
|
import { onNavigate } from "$app/navigation";
|
||||||
|
import { onMount, type SvelteComponent } from "svelte";
|
||||||
|
|
||||||
|
import { formatFileSize } from "$lib/util";
|
||||||
|
import { clearFileStorage, getStorageQuota } from "$lib/storage";
|
||||||
|
|
||||||
|
import { queueVisible } from "$lib/state/queue-visibility";
|
||||||
|
import { currentTasks } from "$lib/state/queen-bee/current-tasks";
|
||||||
|
import { clearQueue, queue as readableQueue } from "$lib/state/queen-bee/queue";
|
||||||
|
|
||||||
|
import SectionHeading from "$components/misc/SectionHeading.svelte";
|
||||||
|
import PopoverContainer from "$components/misc/PopoverContainer.svelte";
|
||||||
|
import ProcessingStatus from "$components/queue/ProcessingStatus.svelte";
|
||||||
|
import ProcessingQueueItem from "$components/queue/ProcessingQueueItem.svelte";
|
||||||
|
import ProcessingQueueStub from "$components/queue/ProcessingQueueStub.svelte";
|
||||||
|
|
||||||
|
import IconX from "@tabler/icons-svelte/IconX.svelte";
|
||||||
|
|
||||||
|
let popover: SvelteComponent;
|
||||||
|
let quotaUsage = 0;
|
||||||
|
|
||||||
|
const updateQuota = async () => {
|
||||||
|
const storageInfo = await getStorageQuota();
|
||||||
|
quotaUsage = storageInfo?.usage || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const popoverAction = () => {
|
||||||
|
$queueVisible = !$queueVisible;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalItemProgress = (completed: number, current: number, total: number) => {
|
||||||
|
return (completed * 100 + current) / total
|
||||||
|
}
|
||||||
|
|
||||||
|
$: queue = Object.entries($readableQueue);
|
||||||
|
|
||||||
|
$: totalProgress = queue.length ? queue.map(([, item]) => {
|
||||||
|
if (item.state === "done" || item.state === "error") {
|
||||||
|
return 100;
|
||||||
|
} else if (item.state === "running") {
|
||||||
|
return totalItemProgress(
|
||||||
|
item.completedWorkers?.length || 0,
|
||||||
|
$currentTasks[item.runningWorker]?.progress?.percentage || 0,
|
||||||
|
item.pipeline.length || 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}).reduce((a, b) => a + b) / (100 * queue.length) : 0;
|
||||||
|
|
||||||
|
$: indeterminate = queue.length > 0 && totalProgress === 0;
|
||||||
|
|
||||||
|
$: if ($queueVisible) {
|
||||||
|
updateQuota();
|
||||||
|
}
|
||||||
|
|
||||||
|
onNavigate(() => {
|
||||||
|
$queueVisible = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// clear old files from storage on first page load
|
||||||
|
clearFileStorage();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="processing-queue" class:expanded={$queueVisible}>
|
||||||
|
<ProcessingStatus
|
||||||
|
progress={totalProgress * 100}
|
||||||
|
{indeterminate}
|
||||||
|
expandAction={popoverAction}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PopoverContainer
|
||||||
|
bind:this={popover}
|
||||||
|
id="processing-popover"
|
||||||
|
expanded={$queueVisible}
|
||||||
|
expandStart="right"
|
||||||
|
>
|
||||||
|
<div id="processing-header">
|
||||||
|
<div class="header-top">
|
||||||
|
<SectionHeading
|
||||||
|
title={$t("queue.title")}
|
||||||
|
sectionId="queue"
|
||||||
|
beta
|
||||||
|
nolink
|
||||||
|
/>
|
||||||
|
<div class="header-buttons">
|
||||||
|
{#if queue.length}
|
||||||
|
<button class="clear-button" on:click={() => {
|
||||||
|
clearQueue();
|
||||||
|
updateQuota();
|
||||||
|
}}>
|
||||||
|
<IconX />
|
||||||
|
{$t("button.clear")}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if quotaUsage}
|
||||||
|
<div class="storage-info">
|
||||||
|
{$t("queue.estimated_storage_usage")} {formatFileSize(quotaUsage)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div id="processing-list">
|
||||||
|
{#each queue as [id, item]}
|
||||||
|
<ProcessingQueueItem
|
||||||
|
{id}
|
||||||
|
info={item}
|
||||||
|
runningWorker={
|
||||||
|
item.state === "running" ? $currentTasks[item.runningWorker] : undefined
|
||||||
|
}
|
||||||
|
runningWorkerId={
|
||||||
|
item.state === "running" ? item.runningWorker : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{#if queue.length === 0}
|
||||||
|
<ProcessingQueueStub />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</PopoverContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#processing-queue {
|
||||||
|
--holder-padding: 16px;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: end;
|
||||||
|
z-index: 9;
|
||||||
|
pointer-events: none;
|
||||||
|
padding: var(--holder-padding);
|
||||||
|
width: calc(100% - var(--holder-padding) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#processing-queue :global(#processing-popover) {
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
padding-bottom: 0;
|
||||||
|
width: calc(100% - 16px * 2);
|
||||||
|
max-width: 425px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#processing-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-buttons button {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-buttons button :global(svg) {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button {
|
||||||
|
color: var(--medium-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
#processing-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
max-height: 65vh;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 535px) {
|
||||||
|
#processing-queue {
|
||||||
|
--holder-padding: 8px;
|
||||||
|
padding-top: 4px;
|
||||||
|
top: env(safe-area-inset-top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
352
web/src/components/queue/ProcessingQueueItem.svelte
Normal file
352
web/src/components/queue/ProcessingQueueItem.svelte
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n/translations";
|
||||||
|
import { formatFileSize } from "$lib/util";
|
||||||
|
import { downloadFile } from "$lib/download";
|
||||||
|
import { removeItem } from "$lib/state/queen-bee/queue";
|
||||||
|
import { savingHandler } from "$lib/api/saving-handler";
|
||||||
|
|
||||||
|
import type { CobaltQueueItem } from "$lib/types/queue";
|
||||||
|
import type { CobaltWorkerProgress } from "$lib/types/workers";
|
||||||
|
import type { CobaltCurrentTaskItem } from "$lib/types/queen-bee";
|
||||||
|
|
||||||
|
import ProgressBar from "$components/queue/ProgressBar.svelte";
|
||||||
|
|
||||||
|
import IconX from "@tabler/icons-svelte/IconX.svelte";
|
||||||
|
import IconCheck from "@tabler/icons-svelte/IconCheck.svelte";
|
||||||
|
import IconReload from "@tabler/icons-svelte/IconReload.svelte";
|
||||||
|
import IconLoader2 from "@tabler/icons-svelte/IconLoader2.svelte";
|
||||||
|
import IconDownload from "@tabler/icons-svelte/IconDownload.svelte";
|
||||||
|
import IconExclamationCircle from "@tabler/icons-svelte/IconExclamationCircle.svelte";
|
||||||
|
|
||||||
|
import IconMovie from "@tabler/icons-svelte/IconMovie.svelte";
|
||||||
|
import IconMusic from "@tabler/icons-svelte/IconMusic.svelte";
|
||||||
|
import IconPhoto from "@tabler/icons-svelte/IconPhoto.svelte";
|
||||||
|
|
||||||
|
const itemIcons = {
|
||||||
|
video: IconMovie,
|
||||||
|
audio: IconMusic,
|
||||||
|
image: IconPhoto,
|
||||||
|
};
|
||||||
|
|
||||||
|
export let id: string;
|
||||||
|
export let info: CobaltQueueItem;
|
||||||
|
export let runningWorker: CobaltCurrentTaskItem | undefined;
|
||||||
|
export let runningWorkerId: string | undefined;
|
||||||
|
|
||||||
|
let retrying = false;
|
||||||
|
|
||||||
|
const retry = async (info: CobaltQueueItem) => {
|
||||||
|
if (info.canRetry && info.originalRequest) {
|
||||||
|
retrying = true;
|
||||||
|
await savingHandler({
|
||||||
|
request: info.originalRequest,
|
||||||
|
});
|
||||||
|
retrying = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = (file: File) =>
|
||||||
|
downloadFile({
|
||||||
|
file: new File([file], info.filename, {
|
||||||
|
type: info.mimeType,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
$: progress = runningWorker?.progress;
|
||||||
|
$: size = formatFileSize(runningWorker?.progress?.size);
|
||||||
|
|
||||||
|
type StatusText = {
|
||||||
|
info: CobaltQueueItem;
|
||||||
|
runningWorker: CobaltCurrentTaskItem | undefined;
|
||||||
|
progress: CobaltWorkerProgress | undefined;
|
||||||
|
size: string;
|
||||||
|
retrying: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateStatusText = ({ info, runningWorker, progress, retrying, size }: StatusText) => {
|
||||||
|
switch (info.state) {
|
||||||
|
case "running":
|
||||||
|
if (runningWorker) {
|
||||||
|
const running = $t(`queue.state.running.${runningWorker.type}`);
|
||||||
|
if (progress && progress.percentage) {
|
||||||
|
return `${running}: ${Math.ceil(progress.percentage)}%, ${size}`;
|
||||||
|
}
|
||||||
|
else if (runningWorker && progress && size) {
|
||||||
|
return `${running}: ${size}`;
|
||||||
|
}
|
||||||
|
else if (runningWorker?.type) {
|
||||||
|
const starting = $t(`queue.state.starting.${runningWorker.type}`);
|
||||||
|
|
||||||
|
if (info.pipeline.length > 1) {
|
||||||
|
const currentPipeline = (info.completedWorkers?.length || 0) + 1;
|
||||||
|
return `${starting} (${currentPipeline}/${info.pipeline.length})`;
|
||||||
|
}
|
||||||
|
return starting;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $t("queue.state.starting");
|
||||||
|
|
||||||
|
case "done":
|
||||||
|
return formatFileSize(info.resultFile?.file?.size);
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
return !retrying ? info.errorCode : $t("queue.state.retrying");
|
||||||
|
|
||||||
|
case "waiting":
|
||||||
|
return $t("queue.state.waiting");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
params are passed here because svelte will re-run
|
||||||
|
the function every time either of them is changed,
|
||||||
|
which is what we want in this case :3
|
||||||
|
*/
|
||||||
|
$: statusText = generateStatusText({
|
||||||
|
info,
|
||||||
|
runningWorker,
|
||||||
|
progress,
|
||||||
|
retrying,
|
||||||
|
size,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="processing-item">
|
||||||
|
<div class="processing-info">
|
||||||
|
<div class="file-title">
|
||||||
|
<div class="processing-type">
|
||||||
|
<svelte:component this={itemIcons[info.mediaType]} />
|
||||||
|
</div>
|
||||||
|
<span class="filename">
|
||||||
|
{info.filename}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if info.state === "running"}
|
||||||
|
<div class="progress-holder">
|
||||||
|
{#each info.pipeline as pipeline}
|
||||||
|
<ProgressBar
|
||||||
|
percentage={progress?.percentage}
|
||||||
|
workerId={pipeline.workerId}
|
||||||
|
{runningWorkerId}
|
||||||
|
completedWorkers={info.completedWorkers}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="file-status {info.state}" class:retrying>
|
||||||
|
<div class="status-icon">
|
||||||
|
{#if info.state === "done"}
|
||||||
|
<IconCheck />
|
||||||
|
{/if}
|
||||||
|
{#if info.state === "error" && !retrying}
|
||||||
|
<IconExclamationCircle />
|
||||||
|
{/if}
|
||||||
|
{#if info.state === "running" || retrying}
|
||||||
|
<div class="status-spinner">
|
||||||
|
<IconLoader2 />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-text">
|
||||||
|
{statusText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-actions">
|
||||||
|
{#if info.state === "done" && info.resultFile}
|
||||||
|
<button
|
||||||
|
class="button action-button"
|
||||||
|
on:click={() => download(info.resultFile.file)}
|
||||||
|
>
|
||||||
|
<IconDownload />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !retrying}
|
||||||
|
{#if info.state === "error" && info?.canRetry}
|
||||||
|
<button
|
||||||
|
class="button action-button"
|
||||||
|
on:click={() => retry(info)}
|
||||||
|
>
|
||||||
|
<IconReload />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="button action-button"
|
||||||
|
on:click={() => removeItem(id)}
|
||||||
|
>
|
||||||
|
<IconX />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.processing-item,
|
||||||
|
.file-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-item {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 0;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1.5px var(--button-elevated) solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-type {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-type :global(svg) {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
stroke-width: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-holder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 4px;
|
||||||
|
line-break: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filename {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: pre;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gray);
|
||||||
|
line-break: anywhere;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-status.error:not(.retrying) {
|
||||||
|
color: var(--medium-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-status :global(svg) {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
stroke-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon,
|
||||||
|
.status-spinner,
|
||||||
|
.status-text {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
margin is used instead of gap cuz queued state doesn't have an icon.
|
||||||
|
margin is applied only to the visible icon, so there's no awkward gap.
|
||||||
|
*/
|
||||||
|
.status-icon :global(svg) {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([dir="rtl"]) .status-icon :global(svg) {
|
||||||
|
margin-left: 6px;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-spinner :global(svg) {
|
||||||
|
animation: spinner 0.7s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.file-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
background-color: var(--button);
|
||||||
|
height: 90%;
|
||||||
|
padding-left: 18px;
|
||||||
|
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0) 0%,
|
||||||
|
rgba(0, 0, 0, 1) 20%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([dir="rtl"]) .file-actions {
|
||||||
|
left: 0;
|
||||||
|
right: unset;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 18px;
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
-90deg,
|
||||||
|
rgba(255, 255, 255, 0) 0%,
|
||||||
|
rgba(0, 0, 0, 1) 20%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-item:hover .file-actions {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.processing-info {
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
padding: 8px;
|
||||||
|
height: auto;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button :global(svg) {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
stroke-width: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-item:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-item:last-child {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
</style>
|
44
web/src/components/queue/ProcessingQueueStub.svelte
Normal file
44
web/src/components/queue/ProcessingQueueStub.svelte
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n/translations";
|
||||||
|
import Meowbalt from "$components/misc/Meowbalt.svelte";
|
||||||
|
|
||||||
|
const stubActions = ["download", "remux"];
|
||||||
|
|
||||||
|
const randomAction = () => {
|
||||||
|
return stubActions[Math.floor(Math.random() * stubActions.length)];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="queue-stub">
|
||||||
|
<Meowbalt emotion="think" />
|
||||||
|
<span class="subtext stub-text">
|
||||||
|
{$t("queue.stub", {
|
||||||
|
value: $t(`queue.stub.${randomAction()}`),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.queue-stub {
|
||||||
|
--base-padding: calc(var(--padding) * 1.5);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gray);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--base-padding);
|
||||||
|
padding-bottom: calc(var(--base-padding) + 16px);
|
||||||
|
text-align: center;
|
||||||
|
gap: var(--padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-stub :global(.meowbalt) {
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub-text {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
133
web/src/components/queue/ProcessingStatus.svelte
Normal file
133
web/src/components/queue/ProcessingStatus.svelte
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import IconArrowDown from "@tabler/icons-svelte/IconArrowDown.svelte";
|
||||||
|
|
||||||
|
export let indeterminate = false;
|
||||||
|
export let progress: number = 0;
|
||||||
|
export let expandAction: () => void;
|
||||||
|
|
||||||
|
$: progressStroke = `${progress}, 100`;
|
||||||
|
const indeterminateStroke = "15, 5";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="processing-status"
|
||||||
|
on:click={expandAction}
|
||||||
|
class="button"
|
||||||
|
class:completed={progress >= 100}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
id="progress-ring"
|
||||||
|
class:indeterminate
|
||||||
|
class:progressive={progress > 0 && !indeterminate}
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="19"
|
||||||
|
cy="19"
|
||||||
|
r="16"
|
||||||
|
fill="none"
|
||||||
|
stroke-dasharray={indeterminate
|
||||||
|
? indeterminateStroke
|
||||||
|
: progressStroke}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="icon-holder">
|
||||||
|
<IconArrowDown />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#processing-status {
|
||||||
|
--processing-status-glow: 0 0 8px 0px var(--button-elevated-hover);
|
||||||
|
|
||||||
|
pointer-events: all;
|
||||||
|
padding: 7px;
|
||||||
|
border-radius: 30px;
|
||||||
|
box-shadow:
|
||||||
|
var(--button-box-shadow),
|
||||||
|
var(--processing-status-glow);
|
||||||
|
|
||||||
|
transition: box-shadow 0.2s, background-color 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#processing-status:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px var(--white) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#processing-status:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#processing-status.completed {
|
||||||
|
box-shadow:
|
||||||
|
var(--focus-ring),
|
||||||
|
var(--processing-status-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) #processing-status.completed {
|
||||||
|
background-color: #e0eeff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="dark"]) #processing-status.completed {
|
||||||
|
background-color: #1f3249;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-holder {
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--button-elevated-hover);
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 20px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-holder :global(svg) {
|
||||||
|
height: 21px;
|
||||||
|
width: 21px;
|
||||||
|
stroke: var(--secondary);
|
||||||
|
stroke-width: 1.5px;
|
||||||
|
transition: stroke 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed .icon-holder {
|
||||||
|
background-color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed .icon-holder :global(svg) {
|
||||||
|
stroke: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-ring {
|
||||||
|
position: absolute;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-ring circle {
|
||||||
|
stroke: var(--blue);
|
||||||
|
stroke-width: 4;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-ring.progressive circle {
|
||||||
|
transition: stroke-dasharray 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-ring.progressive,
|
||||||
|
#progress-ring.indeterminate {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-ring.indeterminate {
|
||||||
|
animation: spinner 3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-ring.indeterminate circle {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed #progress-ring {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
50
web/src/components/queue/ProgressBar.svelte
Normal file
50
web/src/components/queue/ProgressBar.svelte
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Skeleton from "$components/misc/Skeleton.svelte";
|
||||||
|
|
||||||
|
export let percentage: number = 0;
|
||||||
|
export let workerId: string;
|
||||||
|
export let runningWorkerId: string | undefined;
|
||||||
|
export let completedWorkers: string[] = [];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="file-progress">
|
||||||
|
{#if percentage && workerId === runningWorkerId}
|
||||||
|
<div
|
||||||
|
class="progress"
|
||||||
|
style="width: {Math.min(100, percentage || 0)}%"
|
||||||
|
></div>
|
||||||
|
{:else if completedWorkers?.includes(workerId)}
|
||||||
|
<div
|
||||||
|
class="progress"
|
||||||
|
style="width: 100%"
|
||||||
|
></div>
|
||||||
|
{:else if workerId === runningWorkerId}
|
||||||
|
<Skeleton
|
||||||
|
height="6px"
|
||||||
|
width="100%"
|
||||||
|
class="elevated indeterminate-progress"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.file-progress {
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--button-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-progress,
|
||||||
|
.file-progress .progress {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: width 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-progress :global(.indeterminate-progress) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-progress .progress {
|
||||||
|
background-color: var(--blue);
|
||||||
|
}
|
||||||
|
</style>
|
@ -10,7 +10,9 @@
|
|||||||
|
|
||||||
import dialogs from "$lib/state/dialogs";
|
import dialogs from "$lib/state/dialogs";
|
||||||
import { link } from "$lib/state/omnibox";
|
import { link } from "$lib/state/omnibox";
|
||||||
|
import { hapticSwitch } from "$lib/haptics";
|
||||||
import { updateSetting } from "$lib/state/settings";
|
import { updateSetting } from "$lib/state/settings";
|
||||||
|
import { savingHandler } from "$lib/api/saving-handler";
|
||||||
import { pasteLinkFromClipboard } from "$lib/clipboard";
|
import { pasteLinkFromClipboard } from "$lib/clipboard";
|
||||||
import { turnstileEnabled, turnstileSolved } from "$lib/state/turnstile";
|
import { turnstileEnabled, turnstileSolved } from "$lib/state/turnstile";
|
||||||
|
|
||||||
@ -65,6 +67,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hapticSwitch();
|
||||||
|
|
||||||
const pastedData = await pasteLinkFromClipboard();
|
const pastedData = await pasteLinkFromClipboard();
|
||||||
if (!pastedData) return;
|
if (!pastedData) return;
|
||||||
|
|
||||||
@ -75,7 +79,7 @@
|
|||||||
|
|
||||||
if (!isBotCheckOngoing) {
|
if (!isBotCheckOngoing) {
|
||||||
await tick(); // wait for button to render
|
await tick(); // wait for button to render
|
||||||
downloadButton.download($link);
|
savingHandler({ url: $link });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -94,7 +98,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === "Enter" && validLink($link) && isFocused) {
|
if (e.key === "Enter" && validLink($link) && isFocused) {
|
||||||
downloadButton.download($link);
|
savingHandler({ url: $link });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["Escape", "Clear"].includes(e.key) && isFocused) {
|
if (["Escape", "Clear"].includes(e.key) && isFocused) {
|
||||||
@ -217,7 +221,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: 8px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#input-container {
|
#input-container {
|
||||||
@ -242,8 +246,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#input-container.focused {
|
#input-container.focused {
|
||||||
box-shadow: 0 0 0 1.5px var(--secondary) inset;
|
box-shadow: 0 0 0 1px var(--secondary) inset;
|
||||||
outline: var(--secondary) 0.5px solid;
|
outline: var(--secondary) 1px solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
#input-container.focused :global(#input-icons svg) {
|
#input-container.focused :global(#input-icons svg) {
|
||||||
|
@ -3,10 +3,39 @@
|
|||||||
import IconLoader2 from "@tabler/icons-svelte/IconLoader2.svelte";
|
import IconLoader2 from "@tabler/icons-svelte/IconLoader2.svelte";
|
||||||
|
|
||||||
export let loading: boolean;
|
export let loading: boolean;
|
||||||
|
export let animated = !!loading;
|
||||||
|
|
||||||
|
/*
|
||||||
|
initial spinner state is equal to loading state,
|
||||||
|
just so it's animated on init (or not).
|
||||||
|
on transition start, it overrides the value
|
||||||
|
to start spinning (to prevent zooming in with no spinning).
|
||||||
|
|
||||||
|
then, on transition end, when the spinner is hidden,
|
||||||
|
and if loading state is false, the class is removed
|
||||||
|
and the spinner doesn't spin in background while being invisible.
|
||||||
|
|
||||||
|
if loading state is true, then it will just stay spinning
|
||||||
|
(aka when it's visible and should be spinning).
|
||||||
|
|
||||||
|
the spin on transition start is needed for the whirlpool effect
|
||||||
|
of the link icon being sucked into the spinner.
|
||||||
|
|
||||||
|
this may be unnecessarily complicated but i think it looks neat.
|
||||||
|
*/
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="input-icons" class:loading>
|
<div id="input-icons" class:loading>
|
||||||
<div class="input-icon spinner-icon">
|
<div
|
||||||
|
class="input-icon spinner-icon"
|
||||||
|
class:animated
|
||||||
|
on:transitionstart={() => {
|
||||||
|
animated = true;
|
||||||
|
}}
|
||||||
|
on:transitionend={() => {
|
||||||
|
animated = !!loading;
|
||||||
|
}}
|
||||||
|
>
|
||||||
<IconLoader2 />
|
<IconLoader2 />
|
||||||
</div>
|
</div>
|
||||||
<div class="input-icon link-icon">
|
<div class="input-icon link-icon">
|
||||||
@ -49,12 +78,12 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner-icon :global(svg) {
|
.spinner-icon.animated :global(svg) {
|
||||||
animation: spin 0.7s infinite linear;
|
animation: spinner 0.7s infinite linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading .link-icon :global(svg) {
|
.loading .link-icon :global(svg) {
|
||||||
animation: spin 0.7s infinite linear;
|
animation: spinner 0.7s linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading .link-icon {
|
.loading .link-icon {
|
||||||
@ -66,13 +95,4 @@
|
|||||||
transform: none;
|
transform: none;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,18 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from "$lib/i18n/translations";
|
import { t } from "$lib/i18n/translations";
|
||||||
import { getServerInfo } from "$lib/api/server-info";
|
|
||||||
import cachedInfo from "$lib/state/server-info";
|
import cachedInfo from "$lib/state/server-info";
|
||||||
|
import { getServerInfo } from "$lib/api/server-info";
|
||||||
|
|
||||||
|
import type { SvelteComponent } from "svelte";
|
||||||
|
|
||||||
import Skeleton from "$components/misc/Skeleton.svelte";
|
import Skeleton from "$components/misc/Skeleton.svelte";
|
||||||
import IconPlus from "@tabler/icons-svelte/IconPlus.svelte";
|
import IconPlus from "@tabler/icons-svelte/IconPlus.svelte";
|
||||||
|
import PopoverContainer from "$components/misc/PopoverContainer.svelte";
|
||||||
|
|
||||||
let services: string[] = [];
|
let services: string[] = [];
|
||||||
|
|
||||||
let popover: HTMLDivElement;
|
let popover: SvelteComponent;
|
||||||
|
|
||||||
$: expanded = false;
|
$: expanded = false;
|
||||||
|
|
||||||
|
let servicesContainer: HTMLDivElement;
|
||||||
$: loaded = false;
|
$: loaded = false;
|
||||||
$: renderPopover = false;
|
|
||||||
|
|
||||||
const loadInfo = async () => {
|
const loadInfo = async () => {
|
||||||
await getServerInfo();
|
await getServerInfo();
|
||||||
@ -29,19 +32,7 @@
|
|||||||
await loadInfo();
|
await loadInfo();
|
||||||
}
|
}
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
popover.focus();
|
servicesContainer.focus();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const showPopover = async () => {
|
|
||||||
const timeout = !renderPopover;
|
|
||||||
renderPopover = true;
|
|
||||||
|
|
||||||
// 10ms delay to let the popover render for the first time
|
|
||||||
if (timeout) {
|
|
||||||
setTimeout(popoverAction, 10);
|
|
||||||
} else {
|
|
||||||
await popoverAction();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@ -49,7 +40,8 @@
|
|||||||
<div id="supported-services" class:expanded>
|
<div id="supported-services" class:expanded>
|
||||||
<button
|
<button
|
||||||
id="services-button"
|
id="services-button"
|
||||||
on:click={showPopover}
|
class="button"
|
||||||
|
on:click={popoverAction}
|
||||||
aria-label={$t(`save.services.title_${expanded ? "hide" : "show"}`)}
|
aria-label={$t(`save.services.title_${expanded ? "hide" : "show"}`)}
|
||||||
>
|
>
|
||||||
<div class="expand-icon">
|
<div class="expand-icon">
|
||||||
@ -58,33 +50,35 @@
|
|||||||
<span class="title">{$t("save.services.title")}</span>
|
<span class="title">{$t("save.services.title")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if renderPopover}
|
<PopoverContainer
|
||||||
<div id="services-popover">
|
bind:this={popover}
|
||||||
<div
|
id="services-popover"
|
||||||
id="services-container"
|
{expanded}
|
||||||
bind:this={popover}
|
>
|
||||||
tabindex="-1"
|
<div
|
||||||
data-focus-ring-hidden
|
id="services-container"
|
||||||
>
|
bind:this={servicesContainer}
|
||||||
{#if loaded}
|
tabindex="-1"
|
||||||
{#each services as service}
|
data-focus-ring-hidden
|
||||||
<div class="service-item">{service}</div>
|
>
|
||||||
{/each}
|
{#if loaded}
|
||||||
{:else}
|
{#each services as service}
|
||||||
{#each { length: 17 } as _}
|
<div class="service-item">{service}</div>
|
||||||
<Skeleton
|
{/each}
|
||||||
class="elevated"
|
{:else}
|
||||||
width={Math.random() * 44 + 50 + "px"}
|
{#each { length: 17 } as _}
|
||||||
height="24.5px"
|
<Skeleton
|
||||||
/>
|
class="elevated"
|
||||||
{/each}
|
width={Math.random() * 44 + 50 + "px"}
|
||||||
{/if}
|
height="24.5px"
|
||||||
</div>
|
/>
|
||||||
<div id="services-disclaimer" class="subtext">
|
{/each}
|
||||||
{$t("save.services.disclaimer")}
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div id="services-disclaimer" class="subtext">
|
||||||
|
{$t("save.services.disclaimer")}
|
||||||
|
</div>
|
||||||
|
</PopoverContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -97,34 +91,6 @@
|
|||||||
height: 35px;
|
height: 35px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#services-popover {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: var(--button);
|
|
||||||
box-shadow:
|
|
||||||
var(--button-box-shadow),
|
|
||||||
0 0 10px 10px var(--popover-glow);
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
padding: 12px;
|
|
||||||
gap: 6px;
|
|
||||||
top: 6px;
|
|
||||||
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0);
|
|
||||||
transform-origin: top center;
|
|
||||||
|
|
||||||
transition:
|
|
||||||
transform 0.3s cubic-bezier(0.53, 0.05, 0.23, 1.15),
|
|
||||||
opacity 0.25s cubic-bezier(0.53, 0.05, 0.23, 0.99);
|
|
||||||
}
|
|
||||||
|
|
||||||
.expanded #services-popover {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#services-button {
|
#services-button {
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
padding: 7px 13px 7px 10px;
|
padding: 7px 13px 7px 10px;
|
||||||
@ -135,9 +101,10 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: none;
|
background: none;
|
||||||
|
transition: background 0.2s, box-shadow 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
#services-button:not(:focus-visible) {
|
#services-button:not(:focus-visible):not(:active) {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,19 +118,37 @@
|
|||||||
background: var(--button-elevated);
|
background: var(--button-elevated);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
transition: transform 0.2s;
|
transition: background 0.2s, transform 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
#services-button:active .expand-icon {
|
#services-button:active {
|
||||||
background: var(--button-elevated-hover);
|
background: var(--button-hover-transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
|
#services-button:hover {
|
||||||
|
background: var(--button-hover-transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#services-button:active {
|
||||||
|
background: var(--button-press-transparent);
|
||||||
|
}
|
||||||
|
|
||||||
#services-button:hover .expand-icon {
|
#services-button:hover .expand-icon {
|
||||||
background: var(--button-elevated-hover);
|
background: var(--button-elevated-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
#services-button:active {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#services-button:active .expand-icon {
|
||||||
|
background: var(--button-elevated-press);
|
||||||
|
}
|
||||||
|
|
||||||
.expand-icon :global(svg) {
|
.expand-icon :global(svg) {
|
||||||
height: 18px;
|
height: 18px;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
id="clear-button"
|
id="clear-button"
|
||||||
|
class="button"
|
||||||
on:click={click}
|
on:click={click}
|
||||||
aria-label={$t("a11y.save.clear_input")}
|
aria-label={$t("a11y.save.clear_input")}
|
||||||
>
|
>
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import "@fontsource-variable/noto-sans-mono";
|
import "@fontsource-variable/noto-sans-mono";
|
||||||
|
|
||||||
import API from "$lib/api/api";
|
import { onDestroy } from "svelte";
|
||||||
import { t } from "$lib/i18n/translations";
|
import { t } from "$lib/i18n/translations";
|
||||||
import { createDialog } from "$lib/state/dialogs";
|
import { hapticSwitch } from "$lib/haptics";
|
||||||
import { downloadFile } from "$lib/download";
|
import { savingHandler } from "$lib/api/saving-handler";
|
||||||
|
import { downloadButtonState } from "$lib/state/omnibox";
|
||||||
|
|
||||||
import type { DialogInfo } from "$lib/types/dialog";
|
import type { CobaltDownloadButtonState } from "$lib/types/omnibox";
|
||||||
|
|
||||||
export let url: string;
|
export let url: string;
|
||||||
export let disabled = false;
|
export let disabled = false;
|
||||||
@ -15,148 +16,50 @@
|
|||||||
$: buttonText = ">>";
|
$: buttonText = ">>";
|
||||||
$: buttonAltText = $t("a11y.save.download");
|
$: buttonAltText = $t("a11y.save.download");
|
||||||
|
|
||||||
let defaultErrorPopup: DialogInfo = {
|
|
||||||
id: "save-error",
|
|
||||||
type: "small",
|
|
||||||
meowbalt: "error",
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
text: $t("button.gotit"),
|
|
||||||
main: true,
|
|
||||||
action: () => {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
type DownloadButtonState = "idle" | "think" | "check" | "done" | "error";
|
type DownloadButtonState = "idle" | "think" | "check" | "done" | "error";
|
||||||
|
|
||||||
const changeDownloadButton = (state: DownloadButtonState) => {
|
const unsubscribe = downloadButtonState.subscribe(
|
||||||
disabled = state !== "idle";
|
(state: CobaltDownloadButtonState) => {
|
||||||
loading = state === "think" || state === "check";
|
disabled = state !== "idle";
|
||||||
|
loading = state === "think" || state === "check";
|
||||||
|
|
||||||
buttonText = {
|
buttonText = {
|
||||||
idle: ">>",
|
idle: ">>",
|
||||||
think: "...",
|
think: "...",
|
||||||
check: "..?",
|
check: "..?",
|
||||||
done: ">>>",
|
done: ">>>",
|
||||||
error: "!!",
|
error: "!!",
|
||||||
}[state];
|
}[state];
|
||||||
|
|
||||||
buttonAltText = $t(
|
buttonAltText = $t(
|
||||||
{
|
|
||||||
idle: "a11y.save.download",
|
|
||||||
think: "a11y.save.download.think",
|
|
||||||
check: "a11y.save.download.check",
|
|
||||||
done: "a11y.save.download.done",
|
|
||||||
error: "a11y.save.download.error",
|
|
||||||
}[state]
|
|
||||||
);
|
|
||||||
|
|
||||||
// states that don't wait for anything, and thus can
|
|
||||||
// transition back to idle after some period of time.
|
|
||||||
const final: DownloadButtonState[] = ["done", "error"];
|
|
||||||
if (final.includes(state)) {
|
|
||||||
setTimeout(() => changeDownloadButton("idle"), 1500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const download = async (link: string) => {
|
|
||||||
changeDownloadButton("think");
|
|
||||||
|
|
||||||
const response = await API.request(link);
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
changeDownloadButton("error");
|
|
||||||
|
|
||||||
return createDialog({
|
|
||||||
...defaultErrorPopup,
|
|
||||||
bodyText: $t("error.api.unreachable"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === "error") {
|
|
||||||
changeDownloadButton("error");
|
|
||||||
|
|
||||||
return createDialog({
|
|
||||||
...defaultErrorPopup,
|
|
||||||
bodyText: $t(response.error.code, response?.error?.context),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === "redirect") {
|
|
||||||
changeDownloadButton("done");
|
|
||||||
|
|
||||||
return downloadFile({
|
|
||||||
url: response.url,
|
|
||||||
urlType: "redirect",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === "tunnel") {
|
|
||||||
changeDownloadButton("check");
|
|
||||||
|
|
||||||
const probeResult = await API.probeCobaltTunnel(response.url);
|
|
||||||
|
|
||||||
if (probeResult === 200) {
|
|
||||||
changeDownloadButton("done");
|
|
||||||
|
|
||||||
return downloadFile({
|
|
||||||
url: response.url,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
changeDownloadButton("error");
|
|
||||||
|
|
||||||
return createDialog({
|
|
||||||
...defaultErrorPopup,
|
|
||||||
bodyText: $t("error.tunnel.probe"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === "picker") {
|
|
||||||
changeDownloadButton("done");
|
|
||||||
const buttons = [
|
|
||||||
{
|
{
|
||||||
text: $t("button.done"),
|
idle: "a11y.save.download",
|
||||||
main: true,
|
think: "a11y.save.download.think",
|
||||||
action: () => {},
|
check: "a11y.save.download.check",
|
||||||
},
|
done: "a11y.save.download.done",
|
||||||
];
|
error: "a11y.save.download.error",
|
||||||
|
}[state]
|
||||||
|
);
|
||||||
|
|
||||||
if (response.audio) {
|
// states that don't wait for anything, and thus can
|
||||||
const pickerAudio = response.audio;
|
// transition back to idle after some period of time.
|
||||||
buttons.unshift({
|
const final: DownloadButtonState[] = ["done", "error"];
|
||||||
text: $t("button.download.audio"),
|
if (final.includes(state)) {
|
||||||
main: false,
|
setTimeout(() => downloadButtonState.set("idle"), 1500);
|
||||||
action: () => {
|
|
||||||
downloadFile({
|
|
||||||
url: pickerAudio,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return createDialog({
|
|
||||||
id: "download-picker",
|
|
||||||
type: "picker",
|
|
||||||
items: response.picker,
|
|
||||||
buttons,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
changeDownloadButton("error");
|
onDestroy(() => unsubscribe());
|
||||||
|
|
||||||
return createDialog({
|
|
||||||
...defaultErrorPopup,
|
|
||||||
bodyText: $t("error.api.unknown_response"),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="download-button"
|
id="download-button"
|
||||||
{disabled}
|
{disabled}
|
||||||
on:click={() => download(url)}
|
on:click={() => {
|
||||||
|
hapticSwitch();
|
||||||
|
savingHandler({ url });
|
||||||
|
}}
|
||||||
aria-label={buttonAltText}
|
aria-label={buttonAltText}
|
||||||
>
|
>
|
||||||
<span id="download-state">{buttonText}</span>
|
<span id="download-state">{buttonText}</span>
|
||||||
@ -170,9 +73,12 @@
|
|||||||
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 48px;
|
min-width: 48px;
|
||||||
|
width: 48px;
|
||||||
|
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0 12px;
|
|
||||||
|
/* visually align the button, +1.5px because of inset box-shadow on parent */
|
||||||
|
padding: 0 13.5px 0 12px;
|
||||||
|
|
||||||
background: none;
|
background: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@ -194,7 +100,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#download-button:focus-visible {
|
#download-button:focus-visible {
|
||||||
box-shadow: 0 0 0 2px var(--blue) inset;
|
box-shadow: var(--focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
#download-state {
|
#download-state {
|
||||||
@ -212,7 +118,7 @@
|
|||||||
|
|
||||||
#download-button:disabled {
|
#download-button:disabled {
|
||||||
cursor: unset;
|
cursor: unset;
|
||||||
opacity: 0.7;
|
color: var(--gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(#input-container.focused) #download-button {
|
:global(#input-container.focused) #download-button {
|
||||||
@ -225,11 +131,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
#download-button:hover {
|
#download-button:hover:not(:disabled) {
|
||||||
background: var(--button-hover-transparent);
|
background: var(--button-hover-transparent);
|
||||||
}
|
}
|
||||||
#download-button:disabled:hover {
|
}
|
||||||
background: none;
|
|
||||||
}
|
#download-button:active:not(:disabled) {
|
||||||
|
background: var(--button-press-transparent);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
42
web/src/components/settings/ClearStorageButton.svelte
Normal file
42
web/src/components/settings/ClearStorageButton.svelte
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n/translations";
|
||||||
|
import { createDialog } from "$lib/state/dialogs";
|
||||||
|
import { clearQueue } from "$lib/state/queen-bee/queue";
|
||||||
|
import { clearCacheStorage, clearFileStorage } from "$lib/storage";
|
||||||
|
|
||||||
|
import IconFileShredder from "@tabler/icons-svelte/IconFileShredder.svelte";
|
||||||
|
import DataSettingsButton from "$components/settings/DataSettingsButton.svelte";
|
||||||
|
|
||||||
|
const clearDialog = () => {
|
||||||
|
createDialog({
|
||||||
|
id: "wipe-confirm",
|
||||||
|
type: "small",
|
||||||
|
icon: "warn-red",
|
||||||
|
title: $t("dialog.clear_cache.title"),
|
||||||
|
bodyText: $t("dialog.clear_cache.body"),
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: $t("button.cancel"),
|
||||||
|
main: false,
|
||||||
|
action: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: $t("button.clear"),
|
||||||
|
color: "red",
|
||||||
|
main: true,
|
||||||
|
timeout: 2000,
|
||||||
|
action: async () => {
|
||||||
|
clearQueue();
|
||||||
|
await clearFileStorage();
|
||||||
|
await clearCacheStorage();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DataSettingsButton id="clear-cache" click={clearDialog} danger>
|
||||||
|
<IconFileShredder />
|
||||||
|
{$t("button.clear_cache")}
|
||||||
|
</DataSettingsButton>
|
32
web/src/components/settings/DataSettingsButton.svelte
Normal file
32
web/src/components/settings/DataSettingsButton.svelte
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let id: string;
|
||||||
|
export let click: () => void;
|
||||||
|
export let danger = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button {id} class="button data-button" class:danger on:click={click}>
|
||||||
|
<slot></slot>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.data-button {
|
||||||
|
padding: 8px 14px;
|
||||||
|
width: max-content;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-button :global(svg) {
|
||||||
|
stroke-width: 1.8px;
|
||||||
|
height: 21px;
|
||||||
|
width: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-button.danger {
|
||||||
|
background-color: var(--red);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-button.danger:hover {
|
||||||
|
background-color: var(--dark-red);
|
||||||
|
}
|
||||||
|
</style>
|
@ -106,12 +106,16 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 8px;
|
gap: 9px;
|
||||||
padding: 8px var(--padding);
|
padding: 7px var(--padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filename-preview-item:first-child {
|
.filename-preview-item:first-child {
|
||||||
border-bottom: 1.5px var(--button-stroke) solid;
|
border-bottom: 1px var(--button-stroke) solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filename-preview-item:last-child {
|
||||||
|
padding-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-icon {
|
.item-icon {
|
||||||
@ -144,6 +148,7 @@
|
|||||||
|
|
||||||
.item-text .description {
|
.item-text .description {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 750px) {
|
@media screen and (max-width: 750px) {
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
import { validateSettings } from "$lib/settings/validate";
|
import { validateSettings } from "$lib/settings/validate";
|
||||||
import { storedSettings, updateSetting, loadFromString } from "$lib/state/settings";
|
import { storedSettings, updateSetting, loadFromString } from "$lib/state/settings";
|
||||||
|
|
||||||
import ActionButton from "$components/buttons/ActionButton.svelte";
|
import DataSettingsButton from "$components/settings/DataSettingsButton.svelte";
|
||||||
import ResetSettingsButton from "$components/settings/ResetSettingsButton.svelte";
|
import ResetSettingsButton from "$components/settings/ResetSettingsButton.svelte";
|
||||||
|
|
||||||
import IconFileExport from "@tabler/icons-svelte/IconFileExport.svelte";
|
import IconFileExport from "@tabler/icons-svelte/IconFileExport.svelte";
|
||||||
@ -95,16 +95,16 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="button-row" id="settings-data-transfer">
|
<div class="button-row" id="settings-data-transfer">
|
||||||
<ActionButton id="import-settings" click={importSettings}>
|
<DataSettingsButton id="import-settings" click={importSettings}>
|
||||||
<IconFileImport />
|
<IconFileImport />
|
||||||
{$t("button.import")}
|
{$t("button.import")}
|
||||||
</ActionButton>
|
</DataSettingsButton>
|
||||||
|
|
||||||
{#if $storedSettings.schemaVersion}
|
{#if $storedSettings.schemaVersion}
|
||||||
<ActionButton id="export-settings" click={exportSettings}>
|
<DataSettingsButton id="export-settings" click={exportSettings}>
|
||||||
<IconFileExport />
|
<IconFileExport />
|
||||||
{$t("button.export")}
|
{$t("button.export")}
|
||||||
</ActionButton>
|
</DataSettingsButton>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $storedSettings.schemaVersion}
|
{#if $storedSettings.schemaVersion}
|
||||||
|
@ -3,15 +3,16 @@
|
|||||||
import { createDialog } from "$lib/state/dialogs";
|
import { createDialog } from "$lib/state/dialogs";
|
||||||
import { resetSettings } from "$lib/state/settings";
|
import { resetSettings } from "$lib/state/settings";
|
||||||
|
|
||||||
import IconTrash from "@tabler/icons-svelte/IconTrash.svelte";
|
import IconRestore from "@tabler/icons-svelte/IconRestore.svelte";
|
||||||
|
import DataSettingsButton from "$components/settings/DataSettingsButton.svelte";
|
||||||
|
|
||||||
const resetDialog = () => {
|
const resetDialog = () => {
|
||||||
createDialog({
|
createDialog({
|
||||||
id: "wipe-confirm",
|
id: "wipe-confirm",
|
||||||
type: "small",
|
type: "small",
|
||||||
icon: "warn-red",
|
icon: "warn-red",
|
||||||
title: $t("dialog.reset.title"),
|
title: $t("dialog.reset_settings.title"),
|
||||||
bodyText: $t("dialog.reset.body"),
|
bodyText: $t("dialog.reset_settings.body"),
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
text: $t("button.cancel"),
|
text: $t("button.cancel"),
|
||||||
@ -30,26 +31,7 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button id="setting-button-reset" class="button" on:click={resetDialog}>
|
<DataSettingsButton id="reset-settings" click={resetDialog} danger>
|
||||||
<IconTrash />
|
<IconRestore />
|
||||||
{$t("button.reset")}
|
{$t("button.reset")}
|
||||||
</button>
|
</DataSettingsButton>
|
||||||
|
|
||||||
<style>
|
|
||||||
#setting-button-reset {
|
|
||||||
background-color: var(--red);
|
|
||||||
color: var(--white);
|
|
||||||
width: max-content;
|
|
||||||
text-align: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
#setting-button-reset:hover {
|
|
||||||
background-color: var(--dark-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
#setting-button-reset :global(svg) {
|
|
||||||
stroke-width: 2px;
|
|
||||||
height: 24px;
|
|
||||||
width: 24px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { copyURL as _copyURL } from "$lib/download";
|
|
||||||
|
|
||||||
import SectionHeading from "$components/misc/SectionHeading.svelte";
|
import SectionHeading from "$components/misc/SectionHeading.svelte";
|
||||||
|
|
||||||
export let title: string;
|
export let title: string;
|
||||||
|
@ -8,6 +8,7 @@
|
|||||||
import { updateSetting } from "$lib/state/settings";
|
import { updateSetting } from "$lib/state/settings";
|
||||||
import type { CobaltSettings } from "$lib/types/settings";
|
import type { CobaltSettings } from "$lib/types/settings";
|
||||||
|
|
||||||
|
import { hapticConfirm, hapticSwitch } from "$lib/haptics";
|
||||||
import IconSelector from "@tabler/icons-svelte/IconSelector.svelte";
|
import IconSelector from "@tabler/icons-svelte/IconSelector.svelte";
|
||||||
|
|
||||||
export let title: string;
|
export let title: string;
|
||||||
@ -22,8 +23,9 @@
|
|||||||
export let disabled = false;
|
export let disabled = false;
|
||||||
|
|
||||||
const onChange = (event: Event) => {
|
const onChange = (event: Event) => {
|
||||||
const target = event.target as HTMLSelectElement;
|
hapticConfirm();
|
||||||
|
|
||||||
|
const target = event.target as HTMLSelectElement;
|
||||||
updateSetting({
|
updateSetting({
|
||||||
[settingContext]: {
|
[settingContext]: {
|
||||||
[settingId]: target.value,
|
[settingId]: target.value,
|
||||||
@ -46,13 +48,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select on:change={e => onChange(e)} {disabled}>
|
<select
|
||||||
|
on:click={() => hapticSwitch()}
|
||||||
|
on:change={(e) => onChange(e)}
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
{#each Object.keys(items) as value, i}
|
{#each Object.keys(items) as value, i}
|
||||||
<option {value} selected={selectedOption === value}>
|
<option {value} selected={selectedOption === value}>
|
||||||
{items[value]}
|
{items[value]}
|
||||||
</option>
|
</option>
|
||||||
{#if i === 0}
|
{#if i === 0}
|
||||||
<hr>
|
<hr />
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
@ -157,10 +163,4 @@
|
|||||||
background: initial;
|
background: initial;
|
||||||
border: initial;
|
border: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
.selector:hover {
|
|
||||||
background-color: var(--button-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -14,25 +14,59 @@
|
|||||||
|
|
||||||
import IconX from "@tabler/icons-svelte/IconX.svelte";
|
import IconX from "@tabler/icons-svelte/IconX.svelte";
|
||||||
import IconCheck from "@tabler/icons-svelte/IconCheck.svelte";
|
import IconCheck from "@tabler/icons-svelte/IconCheck.svelte";
|
||||||
|
import IconArrowBack from "@tabler/icons-svelte/IconArrowBack.svelte";
|
||||||
|
|
||||||
|
import IconEye from "@tabler/icons-svelte/IconEye.svelte";
|
||||||
|
import IconEyeClosed from "@tabler/icons-svelte/IconEyeClosed.svelte";
|
||||||
|
|
||||||
|
type SettingsInputType = "url" | "uuid";
|
||||||
|
|
||||||
export let settingId: Id;
|
export let settingId: Id;
|
||||||
export let settingContext: Context;
|
export let settingContext: Context;
|
||||||
export let placeholder: string;
|
export let placeholder: string;
|
||||||
export let altText: string;
|
export let altText: string;
|
||||||
export let type: "url" | "uuid" = "url";
|
export let type: "url" | "uuid" = "url";
|
||||||
|
|
||||||
|
export let sensitive = false;
|
||||||
export let showInstanceWarning = false;
|
export let showInstanceWarning = false;
|
||||||
|
|
||||||
const regex = {
|
const regex = {
|
||||||
url: "https?:\\/\\/[a-z0-9.\\-]+(:\\d+)?/?",
|
|
||||||
uuid: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
uuid: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||||
};
|
};
|
||||||
|
|
||||||
let input: HTMLInputElement;
|
let input: HTMLInputElement;
|
||||||
let inputValue: string = String(get(settings)[settingContext][settingId]);
|
let inputValue: string = String(get(settings)[settingContext][settingId]);
|
||||||
let inputFocused = false;
|
let inputFocused = false;
|
||||||
let validInput = false;
|
let validInput = true;
|
||||||
|
|
||||||
const writeToSettings = (value: string, type: "url" | "uuid" | "text") => {
|
let inputHidden = true;
|
||||||
|
|
||||||
|
$: inputType = sensitive && inputHidden ? "password" : "text";
|
||||||
|
|
||||||
|
const checkInput = () => {
|
||||||
|
// mark input as valid if it's empty to allow wiping
|
||||||
|
if (inputValue.length === 0) {
|
||||||
|
validInput = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "url") {
|
||||||
|
try {
|
||||||
|
new URL(inputValue)?.origin?.toString();
|
||||||
|
validInput = true;
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
validInput = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
validInput = new RegExp(regex[type]).test(inputValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeToSettings = (value: string, type: SettingsInputType) => {
|
||||||
|
// we assume that the url is valid and error can't be thrown here
|
||||||
|
// since it was tested before by checkInput()
|
||||||
updateSetting({
|
updateSetting({
|
||||||
[settingContext]: {
|
[settingContext]: {
|
||||||
[settingId]:
|
[settingId]:
|
||||||
@ -46,8 +80,9 @@
|
|||||||
if (showInstanceWarning) {
|
if (showInstanceWarning) {
|
||||||
await customInstanceWarning();
|
await customInstanceWarning();
|
||||||
|
|
||||||
if ($settings.processing.seenCustomWarning && inputValue) {
|
if ($settings.processing.seenCustomWarning) {
|
||||||
return writeToSettings(inputValue, type);
|
// fall back to uuid to allow writing empty strings
|
||||||
|
return writeToSettings(inputValue, inputValue ? type : "uuid");
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -58,49 +93,89 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="settings-input-holder">
|
<div id="settings-input-holder">
|
||||||
<div id="input-container" class:focused={inputFocused} aria-hidden="false">
|
<div
|
||||||
|
id="input-container"
|
||||||
|
class:focused={inputFocused}
|
||||||
|
aria-hidden="false"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="input-box"
|
class="input-box"
|
||||||
bind:this={input}
|
bind:this={input}
|
||||||
bind:value={inputValue}
|
bind:value={inputValue}
|
||||||
on:input={() => (validInput = input.checkValidity())}
|
on:input={() => {
|
||||||
on:input={() => (inputFocused = true)}
|
inputFocused = true;
|
||||||
|
checkInput();
|
||||||
|
}}
|
||||||
on:focus={() => (inputFocused = true)}
|
on:focus={() => (inputFocused = true)}
|
||||||
on:blur={() => (inputFocused = false)}
|
on:blur={() => (inputFocused = false)}
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
maxlength="64"
|
maxlength="64"
|
||||||
pattern={regex[type]}
|
|
||||||
aria-label={altText}
|
aria-label={altText}
|
||||||
aria-hidden="false"
|
aria-hidden="false"
|
||||||
|
aria-invalid={!validInput}
|
||||||
|
{...{ type: inputType }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{#if inputValue.length > 0}
|
||||||
|
<button
|
||||||
|
class="button input-inner-button"
|
||||||
|
on:click={() => {
|
||||||
|
inputValue = "";
|
||||||
|
checkInput();
|
||||||
|
}}
|
||||||
|
aria-label={$t("button.clear_input")}
|
||||||
|
>
|
||||||
|
<IconX />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if sensitive}
|
||||||
|
<button
|
||||||
|
class="button input-inner-button"
|
||||||
|
on:click={() => (inputHidden = !inputHidden)}
|
||||||
|
aria-label={$t(
|
||||||
|
inputHidden ? "button.show_input" : "button.hide_input"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{#if inputHidden}
|
||||||
|
<IconEye />
|
||||||
|
{:else}
|
||||||
|
<IconEyeClosed />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if inputValue.length === 0}
|
{#if inputValue.length === 0}
|
||||||
<span class="input-placeholder" aria-hidden="true">
|
<span class="input-placeholder" aria-hidden="true">
|
||||||
{placeholder}
|
{placeholder}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{#if String($settings[settingContext][settingId]).length > 0}
|
||||||
|
<button
|
||||||
|
class="button input-inner-button"
|
||||||
|
on:click={() => {
|
||||||
|
inputValue = String($settings[settingContext][settingId]);
|
||||||
|
checkInput();
|
||||||
|
}}
|
||||||
|
aria-label={$t("button.restore_input")}
|
||||||
|
>
|
||||||
|
<IconArrowBack />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="settings-input-buttons">
|
<div id="settings-input-buttons">
|
||||||
<button
|
<button
|
||||||
class="settings-input-button"
|
class="button settings-input-button"
|
||||||
aria-label={$t("button.save")}
|
aria-label={$t("button.save")}
|
||||||
disabled={inputValue == $settings[settingContext][settingId] || !validInput}
|
disabled={inputValue === $settings[settingContext][settingId] || !validInput}
|
||||||
on:click={save}
|
on:click={save}
|
||||||
>
|
>
|
||||||
<IconCheck />
|
<IconCheck />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
class="settings-input-button"
|
|
||||||
aria-label={$t("button.reset")}
|
|
||||||
disabled={String($settings[settingContext][settingId]).length <= 0}
|
|
||||||
on:click={() => writeToSettings("", "text")}
|
|
||||||
>
|
|
||||||
<IconX />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -111,7 +186,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#input-container {
|
#input-container {
|
||||||
padding: 0 18px;
|
padding: 0 16px;
|
||||||
|
padding-right: 4px;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
color: var(--secondary);
|
color: var(--secondary);
|
||||||
background-color: var(--button);
|
background-color: var(--button);
|
||||||
@ -124,26 +200,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#input-container,
|
#input-container,
|
||||||
#input-box {
|
.input-box {
|
||||||
font-size: 13.5px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#input-box {
|
.input-box {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--secondary);
|
color: var(--secondary);
|
||||||
border: none;
|
border: none;
|
||||||
padding-block: 0;
|
padding-block: 0;
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
padding: 12px 0;
|
padding: 11.5px 0;
|
||||||
}
|
|
||||||
|
|
||||||
#input-box::placeholder {
|
|
||||||
color: var(--gray);
|
|
||||||
/* fix for firefox */
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-placeholder {
|
.input-placeholder {
|
||||||
@ -153,7 +223,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#input-box:focus-visible {
|
.input-box:focus-visible {
|
||||||
box-shadow: unset !important;
|
box-shadow: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,19 +238,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-input-button {
|
.settings-input-button {
|
||||||
height: 42px;
|
width: 40px;
|
||||||
width: 42px;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-input-button :global(svg) {
|
.settings-input-button :global(svg) {
|
||||||
height: 21px;
|
height: 21px;
|
||||||
width: 21px;
|
width: 21px;
|
||||||
stroke-width: 1.5px;
|
stroke-width: 1.8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-input-button[disabled] {
|
.settings-input-button[disabled] {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-inner-button {
|
||||||
|
height: 34px;
|
||||||
|
width: 34px;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
/* 4px is padding outside of the button */
|
||||||
|
border-radius: calc(var(--border-radius) - 4px);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-inner-button :global(svg) {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
stroke-width: 1.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -11,10 +11,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: calc(var(--padding) * 2);
|
padding: calc(var(--sidebar-tab-padding) * 2);
|
||||||
|
|
||||||
/* accommodate space for scaling animation */
|
/* accommodate space for scaling animation */
|
||||||
padding-bottom: calc(var(--padding) * 2 - var(--sidebar-inner-padding));
|
padding-bottom: calc(var(--sidebar-tab-padding) * 2 - var(--sidebar-inner-padding));
|
||||||
|
}
|
||||||
|
|
||||||
|
#cobalt-logo :global(path) {
|
||||||
|
fill: var(--sidebar-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 535px) {
|
@media screen and (max-width: 535px) {
|
||||||
|
@ -60,7 +60,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--sidebar-inner-padding);
|
padding: var(--sidebar-inner-padding);
|
||||||
padding-bottom: var(--border-radius);
|
padding-bottom: var(--sidebar-tab-padding);
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +79,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
padding: var(--sidebar-inner-padding) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar::before {
|
#sidebar::before {
|
||||||
@ -95,27 +96,26 @@
|
|||||||
#sidebar-tabs {
|
#sidebar-tabs {
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
overflow-x: scroll;
|
overflow-x: scroll;
|
||||||
padding-bottom: 0;
|
padding: 0;
|
||||||
padding: var(--sidebar-inner-padding) 0;
|
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar :global(.sidebar-inner-container:first-child) {
|
#sidebar :global(.sidebar-inner-container:first-child) {
|
||||||
padding-left: calc(var(--border-radius) * 2);
|
padding-left: calc(var(--border-radius) * 1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar :global(.sidebar-inner-container:last-child) {
|
#sidebar :global(.sidebar-inner-container:last-child) {
|
||||||
padding-right: calc(var(--border-radius) * 2);
|
padding-right: calc(var(--border-radius) * 1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar :global(.sidebar-inner-container:first-child:dir(rtl)) {
|
#sidebar :global(.sidebar-inner-container:first-child:dir(rtl)) {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: calc(var(--border-radius) * 2);
|
padding-right: calc(var(--border-radius) * 1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar :global(.sidebar-inner-container:last-child:dir(rtl)) {
|
#sidebar :global(.sidebar-inner-container:last-child:dir(rtl)) {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
padding-left: calc(var(--border-radius) * 2);
|
padding-left: calc(var(--border-radius) * 1.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<svelte:component this={icon} />
|
<svelte:component this={icon} />
|
||||||
{$t(`tabs.${name}`)}
|
<span class="tab-title">{$t(`tabs.${name}`)}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -58,7 +58,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
padding: var(--padding) 3px;
|
padding: var(--sidebar-tab-padding) 3px;
|
||||||
color: var(--sidebar-highlight);
|
color: var(--sidebar-highlight);
|
||||||
font-size: var(--sidebar-font-size);
|
font-size: var(--sidebar-font-size);
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
@ -108,6 +108,14 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-title {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tab:active:not(.active) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pressButton {
|
@keyframes pressButton {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(0.9);
|
transform: scale(0.9);
|
||||||
@ -121,14 +129,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.sidebar-tab:active:not(.active) {
|
.sidebar-tab:hover:not(.active) {
|
||||||
opacity: 1;
|
background-color: var(--button-hover-transparent);
|
||||||
background-color: var(--sidebar-hover);
|
}
|
||||||
|
|
||||||
|
.sidebar-tab:active:not(.active),
|
||||||
|
.sidebar-tab:focus:hover:not(.active) {
|
||||||
|
background-color: var(--button-press-transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tab:hover:not(.active) {
|
.sidebar-tab:hover:not(.active) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background-color: var(--sidebar-hover);
|
}
|
||||||
|
|
||||||
|
.sidebar-tab:active:not(.active),
|
||||||
|
.sidebar-tab:focus:hover:not(.active) {
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 0 0 1px var(--sidebar-stroke) inset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
export let path: string;
|
export let path: string;
|
||||||
export let title: string;
|
export let title: string;
|
||||||
export let icon: ConstructorOfATypedSvelteComponent;
|
export let icon: ConstructorOfATypedSvelteComponent;
|
||||||
export let iconColor: "gray" | "blue" | "green" = "gray";
|
export let iconColor: "gray" | "blue" | "green" | "magenta" | "purple" | "orange" = "gray";
|
||||||
|
|
||||||
$: isActive = $page.url.pathname === path;
|
$: isActive = $page.url.pathname === path;
|
||||||
</script>
|
</script>
|
||||||
@ -17,8 +17,8 @@
|
|||||||
class:active={isActive}
|
class:active={isActive}
|
||||||
role="button"
|
role="button"
|
||||||
>
|
>
|
||||||
<div class="subnav-tab-left">
|
<div class="subnav-tab-left" style="--icon-color: var(--{iconColor})">
|
||||||
<div class="tab-icon" style="background: var(--{iconColor})">
|
<div class="tab-icon">
|
||||||
<svelte:component this={icon} />
|
<svelte:component this={icon} />
|
||||||
</div>
|
</div>
|
||||||
<div class="subnav-tab-text">
|
<div class="subnav-tab-text">
|
||||||
@ -41,7 +41,6 @@
|
|||||||
gap: calc(var(--small-padding) * 2);
|
gap: calc(var(--small-padding) * 2);
|
||||||
padding: var(--big-padding);
|
padding: var(--big-padding);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: var(--primary);
|
|
||||||
color: var(--button-text);
|
color: var(--button-text);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -66,6 +65,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: var(--small-padding);
|
padding: var(--small-padding);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
background: var(--icon-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subnav-tab .tab-icon :global(svg) {
|
.subnav-tab .tab-icon :global(svg) {
|
||||||
@ -75,6 +75,19 @@
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subnav-tab:not(.active) .tab-icon {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
box-shadow: var(--button-box-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="dark"]) .subnav-tab:not(.active) .tab-icon {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subnav-tab:not(.active) .tab-icon :global(svg) {
|
||||||
|
stroke: var(--icon-color);
|
||||||
|
}
|
||||||
|
|
||||||
.subnav-tab-chevron :global(svg) {
|
.subnav-tab-chevron :global(svg) {
|
||||||
display: none;
|
display: none;
|
||||||
stroke-width: 2px;
|
stroke-width: 2px;
|
||||||
@ -93,8 +106,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.subnav-tab:active {
|
.subnav-tab:active,
|
||||||
background: var(--button-hover-transparent);
|
.subnav-tab:focus:hover:not(.active) {
|
||||||
|
background: var(--button-press-transparent);
|
||||||
|
box-shadow: var(--button-box-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subnav-tab.active {
|
.subnav-tab.active {
|
||||||
@ -118,7 +133,7 @@
|
|||||||
.subnav-tab:not(:last-child) {
|
.subnav-tab:not(:last-child) {
|
||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
border-bottom-right-radius: 0;
|
border-bottom-right-radius: 0;
|
||||||
box-shadow: 48px 3px 0px -1.8px var(--button-stroke);
|
box-shadow: 48px 3px 0px -2px var(--button-stroke);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subnav-tab:not(:first-child) {
|
.subnav-tab:not(:first-child) {
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
import settings from "$lib/state/settings";
|
import settings from "$lib/state/settings";
|
||||||
import lazySettingGetter from "$lib/settings/lazy-get";
|
|
||||||
|
|
||||||
import { getSession } from "$lib/api/session";
|
import { getSession } from "$lib/api/session";
|
||||||
import { currentApiURL } from "$lib/api/api-url";
|
import { currentApiURL } from "$lib/api/api-url";
|
||||||
@ -10,7 +9,7 @@ import cachedInfo from "$lib/state/server-info";
|
|||||||
import { getServerInfo } from "$lib/api/server-info";
|
import { getServerInfo } from "$lib/api/server-info";
|
||||||
|
|
||||||
import type { Optional } from "$lib/types/generic";
|
import type { Optional } from "$lib/types/generic";
|
||||||
import type { CobaltAPIResponse, CobaltErrorResponse } from "$lib/types/api";
|
import type { CobaltAPIResponse, CobaltErrorResponse, CobaltSaveRequestBody } from "$lib/types/api";
|
||||||
|
|
||||||
const getAuthorization = async () => {
|
const getAuthorization = async () => {
|
||||||
const processing = get(settings).processing;
|
const processing = get(settings).processing;
|
||||||
@ -43,31 +42,7 @@ const getAuthorization = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = async (url: string) => {
|
const request = async (request: CobaltSaveRequestBody) => {
|
||||||
const getSetting = lazySettingGetter(get(settings));
|
|
||||||
|
|
||||||
const request = {
|
|
||||||
url,
|
|
||||||
|
|
||||||
downloadMode: getSetting("save", "downloadMode"),
|
|
||||||
audioBitrate: getSetting("save", "audioBitrate"),
|
|
||||||
audioFormat: getSetting("save", "audioFormat"),
|
|
||||||
tiktokFullAudio: getSetting("save", "tiktokFullAudio"),
|
|
||||||
youtubeDubLang: getSetting("save", "youtubeDubLang"),
|
|
||||||
|
|
||||||
youtubeVideoCodec: getSetting("save", "youtubeVideoCodec"),
|
|
||||||
videoQuality: getSetting("save", "videoQuality"),
|
|
||||||
youtubeHLS: getSetting("save", "youtubeHLS"),
|
|
||||||
|
|
||||||
filenameStyle: getSetting("save", "filenameStyle"),
|
|
||||||
disableMetadata: getSetting("save", "disableMetadata"),
|
|
||||||
|
|
||||||
twitterGif: getSetting("save", "twitterGif"),
|
|
||||||
tiktokH265: getSetting("save", "tiktokH265"),
|
|
||||||
|
|
||||||
alwaysProxy: getSetting("privacy", "alwaysProxy"),
|
|
||||||
}
|
|
||||||
|
|
||||||
await getServerInfo();
|
await getServerInfo();
|
||||||
|
|
||||||
const getCachedInfo = get(cachedInfo);
|
const getCachedInfo = get(cachedInfo);
|
||||||
|
160
web/src/lib/api/saving-handler.ts
Normal file
160
web/src/lib/api/saving-handler.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import API from "$lib/api/api";
|
||||||
|
import settings from "$lib/state/settings";
|
||||||
|
import lazySettingGetter from "$lib/settings/lazy-get";
|
||||||
|
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { t } from "$lib/i18n/translations";
|
||||||
|
import { downloadFile } from "$lib/download";
|
||||||
|
import { createDialog } from "$lib/state/dialogs";
|
||||||
|
import { downloadButtonState } from "$lib/state/omnibox";
|
||||||
|
import { createSavePipeline } from "$lib/queen-bee/queue";
|
||||||
|
|
||||||
|
import type { DialogInfo } from "$lib/types/dialog";
|
||||||
|
import type { CobaltSaveRequestBody } from "$lib/types/api";
|
||||||
|
|
||||||
|
const defaultErrorPopup: DialogInfo = {
|
||||||
|
id: "save-error",
|
||||||
|
type: "small",
|
||||||
|
meowbalt: "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const savingHandler = async ({ url, request }: { url?: string, request?: CobaltSaveRequestBody }) => {
|
||||||
|
downloadButtonState.set("think");
|
||||||
|
|
||||||
|
const errorButtons = [
|
||||||
|
{
|
||||||
|
text: get(t)("button.gotit"),
|
||||||
|
main: true,
|
||||||
|
action: () => { },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const getSetting = lazySettingGetter(get(settings));
|
||||||
|
|
||||||
|
if (!request && !url) return;
|
||||||
|
|
||||||
|
const selectedRequest = request || {
|
||||||
|
// pointing typescript to the fact that
|
||||||
|
// url is either present or not used at all,
|
||||||
|
// aka in cases when request is present
|
||||||
|
url: url!,
|
||||||
|
|
||||||
|
alwaysProxy: getSetting("save", "alwaysProxy"),
|
||||||
|
localProcessing: getSetting("save", "localProcessing"),
|
||||||
|
downloadMode: getSetting("save", "downloadMode"),
|
||||||
|
|
||||||
|
filenameStyle: getSetting("save", "filenameStyle"),
|
||||||
|
disableMetadata: getSetting("save", "disableMetadata"),
|
||||||
|
|
||||||
|
audioBitrate: getSetting("save", "audioBitrate"),
|
||||||
|
audioFormat: getSetting("save", "audioFormat"),
|
||||||
|
tiktokFullAudio: getSetting("save", "tiktokFullAudio"),
|
||||||
|
youtubeDubLang: getSetting("save", "youtubeDubLang"),
|
||||||
|
|
||||||
|
youtubeVideoCodec: getSetting("save", "youtubeVideoCodec"),
|
||||||
|
videoQuality: getSetting("save", "videoQuality"),
|
||||||
|
youtubeHLS: getSetting("save", "youtubeHLS"),
|
||||||
|
|
||||||
|
convertGif: getSetting("save", "convertGif"),
|
||||||
|
allowH265: getSetting("save", "allowH265"),
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await API.request(selectedRequest);
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
downloadButtonState.set("error");
|
||||||
|
|
||||||
|
return createDialog({
|
||||||
|
...defaultErrorPopup,
|
||||||
|
buttons: errorButtons,
|
||||||
|
bodyText: get(t)("error.api.unreachable"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === "error") {
|
||||||
|
downloadButtonState.set("error");
|
||||||
|
|
||||||
|
return createDialog({
|
||||||
|
...defaultErrorPopup,
|
||||||
|
buttons: errorButtons,
|
||||||
|
bodyText: get(t)(response.error.code, response?.error?.context),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === "redirect") {
|
||||||
|
downloadButtonState.set("done");
|
||||||
|
|
||||||
|
return downloadFile({
|
||||||
|
url: response.url,
|
||||||
|
urlType: "redirect",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === "tunnel") {
|
||||||
|
downloadButtonState.set("check");
|
||||||
|
|
||||||
|
const probeResult = await API.probeCobaltTunnel(response.url);
|
||||||
|
|
||||||
|
if (probeResult === 200) {
|
||||||
|
downloadButtonState.set("done");
|
||||||
|
|
||||||
|
return downloadFile({
|
||||||
|
url: response.url,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
downloadButtonState.set("error");
|
||||||
|
|
||||||
|
return createDialog({
|
||||||
|
...defaultErrorPopup,
|
||||||
|
buttons: errorButtons,
|
||||||
|
bodyText: get(t)("error.tunnel.probe"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === "local-processing") {
|
||||||
|
// TODO: remove debug logging
|
||||||
|
console.log(response);
|
||||||
|
|
||||||
|
downloadButtonState.set("done");
|
||||||
|
return createSavePipeline(response, selectedRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === "picker") {
|
||||||
|
downloadButtonState.set("done");
|
||||||
|
const buttons = [
|
||||||
|
{
|
||||||
|
text: get(t)("button.done"),
|
||||||
|
main: true,
|
||||||
|
action: () => { },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (response.audio) {
|
||||||
|
const pickerAudio = response.audio;
|
||||||
|
buttons.unshift({
|
||||||
|
text: get(t)("button.download.audio"),
|
||||||
|
main: false,
|
||||||
|
action: () => {
|
||||||
|
downloadFile({
|
||||||
|
url: pickerAudio,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return createDialog({
|
||||||
|
id: "download-picker",
|
||||||
|
type: "picker",
|
||||||
|
items: response.picker,
|
||||||
|
buttons,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadButtonState.set("error");
|
||||||
|
return createDialog({
|
||||||
|
...defaultErrorPopup,
|
||||||
|
buttons: errorButtons,
|
||||||
|
bodyText: get(t)("error.api.unknown_response"),
|
||||||
|
});
|
||||||
|
}
|
@ -14,6 +14,9 @@ const device = {
|
|||||||
android: false,
|
android: false,
|
||||||
mobile: false,
|
mobile: false,
|
||||||
},
|
},
|
||||||
|
browser: {
|
||||||
|
chrome: false,
|
||||||
|
},
|
||||||
prefers: {
|
prefers: {
|
||||||
language: "en",
|
language: "en",
|
||||||
reducedMotion: false,
|
reducedMotion: false,
|
||||||
@ -22,6 +25,7 @@ const device = {
|
|||||||
supports: {
|
supports: {
|
||||||
share: false,
|
share: false,
|
||||||
directDownload: false,
|
directDownload: false,
|
||||||
|
haptics: false,
|
||||||
},
|
},
|
||||||
userAgent: "sveltekit server",
|
userAgent: "sveltekit server",
|
||||||
}
|
}
|
||||||
@ -32,6 +36,9 @@ if (browser) {
|
|||||||
const iPhone = ua.includes("iphone os");
|
const iPhone = ua.includes("iphone os");
|
||||||
const iPad = !iPhone && ua.includes("mac os") && navigator.maxTouchPoints > 0;
|
const iPad = !iPhone && ua.includes("mac os") && navigator.maxTouchPoints > 0;
|
||||||
|
|
||||||
|
const iosVersion = Number(ua.match(/iphone os (\d+)_/)?.[1]);
|
||||||
|
const modernIOS = iPhone && iosVersion >= 18;
|
||||||
|
|
||||||
const iOS = iPhone || iPad;
|
const iOS = iPhone || iPad;
|
||||||
const android = ua.includes("android") || ua.includes("diordna");
|
const android = ua.includes("android") || ua.includes("diordna");
|
||||||
|
|
||||||
@ -42,11 +49,16 @@ if (browser) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
device.is = {
|
device.is = {
|
||||||
|
mobile: iOS || android,
|
||||||
|
android,
|
||||||
|
|
||||||
iPhone,
|
iPhone,
|
||||||
iPad,
|
iPad,
|
||||||
iOS,
|
iOS,
|
||||||
android,
|
};
|
||||||
mobile: iOS || android,
|
|
||||||
|
device.browser = {
|
||||||
|
chrome: ua.includes("chrome/"),
|
||||||
};
|
};
|
||||||
|
|
||||||
device.prefers = {
|
device.prefers = {
|
||||||
@ -58,6 +70,10 @@ if (browser) {
|
|||||||
device.supports = {
|
device.supports = {
|
||||||
share: navigator.share !== undefined,
|
share: navigator.share !== undefined,
|
||||||
directDownload: !(installed && iOS),
|
directDownload: !(installed && iOS),
|
||||||
|
|
||||||
|
// not sure if vibrations feel the same on android,
|
||||||
|
// so they're enabled only on ios 18+ for now
|
||||||
|
haptics: modernIOS,
|
||||||
};
|
};
|
||||||
|
|
||||||
device.userAgent = navigator.userAgent;
|
device.userAgent = navigator.userAgent;
|
||||||
|
@ -14,6 +14,8 @@ const variables = {
|
|||||||
PLAUSIBLE_HOST: getEnv('PLAUSIBLE_HOST'),
|
PLAUSIBLE_HOST: getEnv('PLAUSIBLE_HOST'),
|
||||||
PLAUSIBLE_ENABLED: getEnv('HOST') && getEnv('PLAUSIBLE_HOST'),
|
PLAUSIBLE_ENABLED: getEnv('HOST') && getEnv('PLAUSIBLE_HOST'),
|
||||||
DEFAULT_API: getEnv('DEFAULT_API'),
|
DEFAULT_API: getEnv('DEFAULT_API'),
|
||||||
|
// temporary variable until webcodecs features are ready for testing
|
||||||
|
ENABLE_WEBCODECS: !!getEnv('ENABLE_WEBCODECS'),
|
||||||
}
|
}
|
||||||
|
|
||||||
const contacts = {
|
const contacts = {
|
||||||
|
43
web/src/lib/haptics.ts
Normal file
43
web/src/lib/haptics.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { get } from "svelte/store";
|
||||||
|
import { device } from "$lib/device";
|
||||||
|
import settings from "$lib/state/settings";
|
||||||
|
|
||||||
|
const canUseHaptics = () => {
|
||||||
|
return device.supports.haptics && !get(settings).accessibility.disableHaptics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hapticSwitch = () => {
|
||||||
|
if (!canUseHaptics()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.ariaHidden = "true";
|
||||||
|
label.style.display = "none";
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "checkbox";
|
||||||
|
input.setAttribute("switch", "");
|
||||||
|
label.appendChild(input);
|
||||||
|
|
||||||
|
document.head.appendChild(label);
|
||||||
|
label.click();
|
||||||
|
document.head.removeChild(label);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hapticConfirm = () => {
|
||||||
|
if (!canUseHaptics()) return;
|
||||||
|
|
||||||
|
hapticSwitch();
|
||||||
|
setTimeout(() => hapticSwitch(), 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hapticError = () => {
|
||||||
|
if (!canUseHaptics()) return;
|
||||||
|
|
||||||
|
hapticSwitch();
|
||||||
|
setTimeout(() => hapticSwitch(), 120);
|
||||||
|
setTimeout(() => hapticSwitch(), 240);
|
||||||
|
}
|
@ -1,8 +1,8 @@
|
|||||||
import mime from "mime";
|
import { OPFSStorage } from "$lib/storage";
|
||||||
import LibAV, { type LibAV as LibAVInstance } from "@imput/libav.js-remux-cli";
|
import LibAV, { type LibAV as LibAVInstance } from "@imput/libav.js-remux-cli";
|
||||||
import type { FFmpegProgressCallback, FFmpegProgressEvent, FFmpegProgressStatus, FileInfo, RenderParams } from "./types/libav";
|
|
||||||
import type { FfprobeData } from "fluent-ffmpeg";
|
import type { FfprobeData } from "fluent-ffmpeg";
|
||||||
import { browser } from "$app/environment";
|
import type { FFmpegProgressCallback, FFmpegProgressEvent, FFmpegProgressStatus, RenderParams } from "$lib/types/libav";
|
||||||
|
|
||||||
export default class LibAVWrapper {
|
export default class LibAVWrapper {
|
||||||
libav: Promise<LibAVInstance> | null;
|
libav: Promise<LibAVInstance> | null;
|
||||||
@ -11,14 +11,18 @@ export default class LibAVWrapper {
|
|||||||
|
|
||||||
constructor(onProgress?: FFmpegProgressCallback) {
|
constructor(onProgress?: FFmpegProgressCallback) {
|
||||||
this.libav = null;
|
this.libav = null;
|
||||||
this.concurrency = Math.min(4, browser ? navigator.hardwareConcurrency : 0);
|
this.concurrency = Math.min(4, navigator.hardwareConcurrency || 0);
|
||||||
this.onProgress = onProgress;
|
this.onProgress = onProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init(options?: LibAV.LibAVOpts) {
|
||||||
|
if (!options) options = {
|
||||||
|
yesthreads: true,
|
||||||
|
}
|
||||||
|
|
||||||
if (this.concurrency && !this.libav) {
|
if (this.concurrency && !this.libav) {
|
||||||
this.libav = LibAV.LibAV({
|
this.libav = LibAV.LibAV({
|
||||||
yesthreads: true,
|
...options,
|
||||||
base: '/_libav'
|
base: '/_libav'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -35,6 +39,8 @@ export default class LibAVWrapper {
|
|||||||
if (!this.libav) throw new Error("LibAV wasn't initialized");
|
if (!this.libav) throw new Error("LibAV wasn't initialized");
|
||||||
const libav = await this.libav;
|
const libav = await this.libav;
|
||||||
|
|
||||||
|
console.log('yay loaded libav :3');
|
||||||
|
|
||||||
await libav.mkreadaheadfile('input', blob);
|
await libav.mkreadaheadfile('input', blob);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -57,60 +63,31 @@ export default class LibAVWrapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static getExtensionFromType(blob: Blob) {
|
async render({ files, output, args }: RenderParams) {
|
||||||
const extensions = mime.getAllExtensions(blob.type);
|
|
||||||
const overrides = ['mp3', 'mov'];
|
|
||||||
|
|
||||||
if (!extensions)
|
|
||||||
return;
|
|
||||||
|
|
||||||
for (const override of overrides)
|
|
||||||
if (extensions?.has(override))
|
|
||||||
return override;
|
|
||||||
|
|
||||||
return [...extensions][0];
|
|
||||||
}
|
|
||||||
|
|
||||||
async render({ blob, output, args }: RenderParams) {
|
|
||||||
if (!this.libav) throw new Error("LibAV wasn't initialized");
|
if (!this.libav) throw new Error("LibAV wasn't initialized");
|
||||||
const libav = await this.libav;
|
const libav = await this.libav;
|
||||||
const inputKind = blob.type.split("/")[0];
|
|
||||||
const inputExtension = LibAVWrapper.getExtensionFromType(blob);
|
|
||||||
|
|
||||||
if (inputKind !== "video" && inputKind !== "audio") return;
|
if (!(output.format && output.type)) {
|
||||||
if (!inputExtension) return;
|
throw new Error("output's format or type is missing");
|
||||||
|
|
||||||
const input: FileInfo = {
|
|
||||||
kind: inputKind,
|
|
||||||
extension: inputExtension,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!output) output = input;
|
const outputName = `output.${output.format}`;
|
||||||
|
const ffInputs = [];
|
||||||
output.type = mime.getType(output.extension);
|
|
||||||
if (!output.type) return;
|
|
||||||
|
|
||||||
const outputName = `output.${output.extension}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await libav.mkreadaheadfile("input", blob);
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i].file;
|
||||||
|
|
||||||
|
await libav.mkreadaheadfile(`input${i}`, file);
|
||||||
|
ffInputs.push('-i', `input${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
// https://github.com/Yahweasel/libav.js/blob/7d359f69/docs/IO.md#block-writer-devices
|
|
||||||
await libav.mkwriterdev(outputName);
|
await libav.mkwriterdev(outputName);
|
||||||
await libav.mkwriterdev('progress.txt');
|
await libav.mkwriterdev('progress.txt');
|
||||||
|
|
||||||
const MB = 1024 * 1024;
|
const storage = await OPFSStorage.init();
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
const chunkSize = Math.min(512 * MB, blob.size);
|
|
||||||
|
|
||||||
// since we expect the output file to be roughly the same size
|
libav.onwrite = async (name, pos, data) => {
|
||||||
// as the original, preallocate its size for the output
|
|
||||||
for (let toAllocate = blob.size; toAllocate > 0; toAllocate -= chunkSize) {
|
|
||||||
chunks.push(new Uint8Array(chunkSize));
|
|
||||||
}
|
|
||||||
|
|
||||||
let actualSize = 0;
|
|
||||||
libav.onwrite = (name, pos, data) => {
|
|
||||||
if (name === 'progress.txt') {
|
if (name === 'progress.txt') {
|
||||||
try {
|
try {
|
||||||
return this.#emitProgress(data);
|
return this.#emitProgress(data);
|
||||||
@ -119,26 +96,7 @@ export default class LibAVWrapper {
|
|||||||
}
|
}
|
||||||
} else if (name !== outputName) return;
|
} else if (name !== outputName) return;
|
||||||
|
|
||||||
const writeEnd = pos + data.length;
|
await storage.write(data, pos);
|
||||||
if (writeEnd > chunkSize * chunks.length) {
|
|
||||||
chunks.push(new Uint8Array(chunkSize));
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunkIndex = pos / chunkSize | 0;
|
|
||||||
const offset = pos - (chunkSize * chunkIndex);
|
|
||||||
|
|
||||||
if (offset + data.length > chunkSize) {
|
|
||||||
chunks[chunkIndex].set(
|
|
||||||
data.subarray(0, chunkSize - offset), offset
|
|
||||||
);
|
|
||||||
chunks[chunkIndex + 1].set(
|
|
||||||
data.subarray(chunkSize - offset), 0
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
chunks[chunkIndex].set(data, offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
actualSize = Math.max(writeEnd, actualSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await libav.ffmpeg([
|
await libav.ffmpeg([
|
||||||
@ -146,40 +104,28 @@ export default class LibAVWrapper {
|
|||||||
'-loglevel', 'error',
|
'-loglevel', 'error',
|
||||||
'-progress', 'progress.txt',
|
'-progress', 'progress.txt',
|
||||||
'-threads', this.concurrency.toString(),
|
'-threads', this.concurrency.toString(),
|
||||||
'-i', 'input',
|
...ffInputs,
|
||||||
...args,
|
...args,
|
||||||
outputName
|
outputName
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// if we didn't need as much space as we allocated for some reason,
|
const file = await storage.res();
|
||||||
// shrink the buffers so that we don't inflate the file with zeroes
|
|
||||||
const outputView: Uint8Array[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < chunks.length; ++i) {
|
if (file.size === 0) return;
|
||||||
outputView.push(
|
|
||||||
chunks[i].subarray(
|
|
||||||
0, Math.min(chunkSize, actualSize)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
actualSize -= chunkSize;
|
return {
|
||||||
if (actualSize <= 0) {
|
file,
|
||||||
break;
|
type: output.type,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderBlob = new Blob(
|
|
||||||
outputView,
|
|
||||||
{ type: output.type }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (renderBlob.size === 0) return;
|
|
||||||
return renderBlob;
|
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await libav.unlink(outputName);
|
await libav.unlink(outputName);
|
||||||
await libav.unlink('progress.txt');
|
await libav.unlink('progress.txt');
|
||||||
await libav.unlinkreadaheadfile("input");
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
files.map((_, i) =>
|
||||||
|
libav.unlinkreadaheadfile(`input${i}`)
|
||||||
|
));
|
||||||
} catch { /* catch & ignore */ }
|
} catch { /* catch & ignore */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -192,7 +138,7 @@ export default class LibAVWrapper {
|
|||||||
const entries = Object.fromEntries(
|
const entries = Object.fromEntries(
|
||||||
text.split('\n')
|
text.split('\n')
|
||||||
.filter(a => a)
|
.filter(a => a)
|
||||||
.map(a => a.split('=', ))
|
.map(a => a.split('='))
|
||||||
);
|
);
|
||||||
|
|
||||||
const status: FFmpegProgressStatus = (() => {
|
const status: FFmpegProgressStatus = (() => {
|
||||||
|
104
web/src/lib/queen-bee/queue.ts
Normal file
104
web/src/lib/queen-bee/queue.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { addItem } from "$lib/state/queen-bee/queue";
|
||||||
|
import { openQueuePopover } from "$lib/state/queue-visibility";
|
||||||
|
|
||||||
|
import type { CobaltPipelineItem } from "$lib/types/workers";
|
||||||
|
import type { CobaltLocalProcessingResponse, CobaltSaveRequestBody } from "$lib/types/api";
|
||||||
|
|
||||||
|
export const getMediaType = (type: string) => {
|
||||||
|
const kind = type.split('/')[0];
|
||||||
|
|
||||||
|
// can't use .includes() here for some reason
|
||||||
|
if (kind === "video" || kind === "audio" || kind === "image") {
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createRemuxPipeline = (file: File) => {
|
||||||
|
// chopped khia
|
||||||
|
const parentId = crypto.randomUUID();
|
||||||
|
const mediaType = getMediaType(file.type);
|
||||||
|
|
||||||
|
const pipeline: CobaltPipelineItem[] = [{
|
||||||
|
worker: "remux",
|
||||||
|
workerId: crypto.randomUUID(),
|
||||||
|
parentId,
|
||||||
|
workerArgs: {
|
||||||
|
files: [{
|
||||||
|
file,
|
||||||
|
type: file.type,
|
||||||
|
}],
|
||||||
|
ffargs: [
|
||||||
|
"-c", "copy",
|
||||||
|
"-map", "0"
|
||||||
|
],
|
||||||
|
output: {
|
||||||
|
type: file.type,
|
||||||
|
format: file.name.split(".").pop(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (mediaType) {
|
||||||
|
addItem({
|
||||||
|
id: parentId,
|
||||||
|
state: "waiting",
|
||||||
|
pipeline,
|
||||||
|
filename: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
mediaType,
|
||||||
|
});
|
||||||
|
|
||||||
|
openQueuePopover();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createSavePipeline = (info: CobaltLocalProcessingResponse, request: CobaltSaveRequestBody) => {
|
||||||
|
// TODO: proper error here
|
||||||
|
if (!(info.output?.filename && info.output?.type)) return;
|
||||||
|
|
||||||
|
const parentId = crypto.randomUUID();
|
||||||
|
const pipeline: CobaltPipelineItem[] = [];
|
||||||
|
|
||||||
|
// reverse is needed for audio (second item) to be downloaded first
|
||||||
|
const tunnels = info.tunnel.reverse();
|
||||||
|
|
||||||
|
for (const tunnel of tunnels) {
|
||||||
|
pipeline.push({
|
||||||
|
worker: "fetch",
|
||||||
|
workerId: crypto.randomUUID(),
|
||||||
|
parentId,
|
||||||
|
workerArgs: {
|
||||||
|
url: tunnel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeline.push({
|
||||||
|
worker: "remux",
|
||||||
|
workerId: crypto.randomUUID(),
|
||||||
|
parentId,
|
||||||
|
workerArgs: {
|
||||||
|
ffargs: [
|
||||||
|
"-c:v", "copy",
|
||||||
|
"-c:a", "copy"
|
||||||
|
],
|
||||||
|
output: {
|
||||||
|
type: info.output.type,
|
||||||
|
format: info.output.filename.split(".").pop(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
addItem({
|
||||||
|
id: parentId,
|
||||||
|
state: "waiting",
|
||||||
|
pipeline,
|
||||||
|
canRetry: true,
|
||||||
|
originalRequest: request,
|
||||||
|
filename: info.output.filename,
|
||||||
|
mimeType: info.output.type,
|
||||||
|
mediaType: "video",
|
||||||
|
});
|
||||||
|
|
||||||
|
openQueuePopover();
|
||||||
|
}
|
50
web/src/lib/queen-bee/run-worker.ts
Normal file
50
web/src/lib/queen-bee/run-worker.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { get } from "svelte/store";
|
||||||
|
import { queue } from "$lib/state/queen-bee/queue";
|
||||||
|
|
||||||
|
import { runRemuxWorker } from "$lib/queen-bee/runners/remux";
|
||||||
|
import { runFetchWorker } from "$lib/queen-bee/runners/fetch";
|
||||||
|
|
||||||
|
import type { CobaltPipelineItem } from "$lib/types/workers";
|
||||||
|
import type { CobaltFileReference } from "$lib/types/storage";
|
||||||
|
|
||||||
|
export const killWorker = (worker: Worker, unsubscribe: () => void, interval?: NodeJS.Timeout) => {
|
||||||
|
unsubscribe();
|
||||||
|
worker.terminate();
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const startWorker = async ({ worker, workerId, parentId, workerArgs }: CobaltPipelineItem) => {
|
||||||
|
let files: CobaltFileReference[] = [];
|
||||||
|
|
||||||
|
switch (worker) {
|
||||||
|
case "remux":
|
||||||
|
if (workerArgs?.files) {
|
||||||
|
files = workerArgs.files;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files?.length === 0) {
|
||||||
|
const parent = get(queue)[parentId];
|
||||||
|
if (parent.state === "running" && parent.pipelineResults) {
|
||||||
|
files = parent.pipelineResults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0 && workerArgs.ffargs && workerArgs.output) {
|
||||||
|
await runRemuxWorker(
|
||||||
|
workerId,
|
||||||
|
parentId,
|
||||||
|
files,
|
||||||
|
workerArgs.ffargs,
|
||||||
|
workerArgs.output,
|
||||||
|
true, // resetStartCounter
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "fetch":
|
||||||
|
if (workerArgs?.url) {
|
||||||
|
await runFetchWorker(workerId, parentId, workerArgs.url)
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
51
web/src/lib/queen-bee/runners/fetch.ts
Normal file
51
web/src/lib/queen-bee/runners/fetch.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import FetchWorker from "$lib/workers/fetch?worker";
|
||||||
|
|
||||||
|
import { killWorker } from "$lib/queen-bee/run-worker";
|
||||||
|
import { updateWorkerProgress } from "$lib/state/queen-bee/current-tasks";
|
||||||
|
import { pipelineTaskDone, itemError, queue } from "$lib/state/queen-bee/queue";
|
||||||
|
|
||||||
|
import type { CobaltQueue } from "$lib/types/queue";
|
||||||
|
|
||||||
|
export const runFetchWorker = async (workerId: string, parentId: string, url: string) => {
|
||||||
|
const worker = new FetchWorker();
|
||||||
|
|
||||||
|
const unsubscribe = queue.subscribe((queue: CobaltQueue) => {
|
||||||
|
if (!queue[parentId]) {
|
||||||
|
// TODO: remove logging
|
||||||
|
console.log("worker's parent is gone, so it killed itself");
|
||||||
|
killWorker(worker, unsubscribe);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.postMessage({
|
||||||
|
cobaltFetchWorker: {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.onmessage = (event) => {
|
||||||
|
const eventData = event.data.cobaltFetchWorker;
|
||||||
|
if (!eventData) return;
|
||||||
|
|
||||||
|
if (eventData.progress) {
|
||||||
|
updateWorkerProgress(workerId, {
|
||||||
|
percentage: eventData.progress,
|
||||||
|
size: eventData.size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventData.result) {
|
||||||
|
killWorker(worker, unsubscribe);
|
||||||
|
return pipelineTaskDone(
|
||||||
|
parentId,
|
||||||
|
workerId,
|
||||||
|
eventData.result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventData.error) {
|
||||||
|
killWorker(worker, unsubscribe);
|
||||||
|
return itemError(parentId, workerId, eventData.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
109
web/src/lib/queen-bee/runners/remux.ts
Normal file
109
web/src/lib/queen-bee/runners/remux.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import RemuxWorker from "$lib/workers/remux?worker";
|
||||||
|
|
||||||
|
import { killWorker } from "$lib/queen-bee/run-worker";
|
||||||
|
import { updateWorkerProgress } from "$lib/state/queen-bee/current-tasks";
|
||||||
|
import { pipelineTaskDone, itemError, queue } from "$lib/state/queen-bee/queue";
|
||||||
|
|
||||||
|
import type { FileInfo } from "$lib/types/libav";
|
||||||
|
import type { CobaltQueue } from "$lib/types/queue";
|
||||||
|
import type { CobaltFileReference } from "$lib/types/storage";
|
||||||
|
|
||||||
|
let startAttempts = 0;
|
||||||
|
|
||||||
|
export const runRemuxWorker = async (
|
||||||
|
workerId: string,
|
||||||
|
parentId: string,
|
||||||
|
files: CobaltFileReference[],
|
||||||
|
args: string[],
|
||||||
|
output: FileInfo,
|
||||||
|
resetStartCounter?: boolean
|
||||||
|
) => {
|
||||||
|
const worker = new RemuxWorker();
|
||||||
|
|
||||||
|
// sometimes chrome refuses to start libav wasm,
|
||||||
|
// so we check if it started, try 10 more times if not, and kill self if it still doesn't work
|
||||||
|
// TODO: fix the underlying issue because this is ridiculous
|
||||||
|
|
||||||
|
if (resetStartCounter) startAttempts = 0;
|
||||||
|
|
||||||
|
let bumpAttempts = 0;
|
||||||
|
const startCheck = setInterval(async () => {
|
||||||
|
bumpAttempts++;
|
||||||
|
|
||||||
|
if (bumpAttempts === 10) {
|
||||||
|
startAttempts++;
|
||||||
|
if (startAttempts <= 10) {
|
||||||
|
killWorker(worker, unsubscribe, startCheck);
|
||||||
|
console.error("worker didn't start after 5 seconds, so it was killed and started again");
|
||||||
|
return await runRemuxWorker(workerId, parentId, files, args, output);
|
||||||
|
} else {
|
||||||
|
killWorker(worker, unsubscribe, startCheck);
|
||||||
|
console.error("worker didn't start after 10 attempts, so we're giving up");
|
||||||
|
|
||||||
|
// TODO: proper error code
|
||||||
|
return itemError(parentId, workerId, "worker didn't start");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
const unsubscribe = queue.subscribe((queue: CobaltQueue) => {
|
||||||
|
if (!queue[parentId]) {
|
||||||
|
// TODO: remove logging
|
||||||
|
console.log("worker's parent is gone, so it killed itself");
|
||||||
|
killWorker(worker, unsubscribe, startCheck);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.postMessage({
|
||||||
|
cobaltRemuxWorker: {
|
||||||
|
files,
|
||||||
|
args,
|
||||||
|
output,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.onerror = (e) => {
|
||||||
|
console.error("remux worker exploded:", e);
|
||||||
|
killWorker(worker, unsubscribe, startCheck);
|
||||||
|
|
||||||
|
// TODO: proper error code
|
||||||
|
return itemError(parentId, workerId, "internal error");
|
||||||
|
};
|
||||||
|
|
||||||
|
let totalDuration: number | null = null;
|
||||||
|
|
||||||
|
worker.onmessage = (event) => {
|
||||||
|
const eventData = event.data.cobaltRemuxWorker;
|
||||||
|
if (!eventData) return;
|
||||||
|
|
||||||
|
clearInterval(startCheck);
|
||||||
|
|
||||||
|
// temporary debug logging
|
||||||
|
console.log(JSON.stringify(eventData, null, 2));
|
||||||
|
|
||||||
|
if (eventData.progress) {
|
||||||
|
if (eventData.progress.duration) {
|
||||||
|
totalDuration = eventData.progress.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWorkerProgress(workerId, {
|
||||||
|
percentage: totalDuration ? (eventData.progress.durationProcessed / totalDuration) * 100 : 0,
|
||||||
|
size: eventData.progress.size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventData.render) {
|
||||||
|
killWorker(worker, unsubscribe, startCheck);
|
||||||
|
return pipelineTaskDone(
|
||||||
|
parentId,
|
||||||
|
workerId,
|
||||||
|
eventData.render,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventData.error) {
|
||||||
|
killWorker(worker, unsubscribe, startCheck);
|
||||||
|
return itemError(parentId, workerId, eventData.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
68
web/src/lib/queen-bee/scheduler.ts
Normal file
68
web/src/lib/queen-bee/scheduler.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { get } from "svelte/store";
|
||||||
|
import { startWorker } from "$lib/queen-bee/run-worker";
|
||||||
|
import { itemDone, itemError, itemRunning, queue } from "$lib/state/queen-bee/queue";
|
||||||
|
import { addWorkerToQueue, currentTasks } from "$lib/state/queen-bee/current-tasks";
|
||||||
|
import type { CobaltPipelineItem } from "$lib/types/workers";
|
||||||
|
|
||||||
|
const startPipeline = (pipelineItem: CobaltPipelineItem) => {
|
||||||
|
addWorkerToQueue(pipelineItem.workerId, {
|
||||||
|
type: pipelineItem.worker,
|
||||||
|
parentId: pipelineItem.parentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
itemRunning(
|
||||||
|
pipelineItem.parentId,
|
||||||
|
pipelineItem.workerId,
|
||||||
|
);
|
||||||
|
|
||||||
|
startWorker(pipelineItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const checkTasks = () => {
|
||||||
|
const queueItems = get(queue);
|
||||||
|
const ongoingTasks = get(currentTasks);
|
||||||
|
|
||||||
|
// TODO (?): task concurrency
|
||||||
|
if (Object.keys(ongoingTasks).length > 0) return;
|
||||||
|
|
||||||
|
for (const item of Object.keys(queueItems)) {
|
||||||
|
const task = queueItems[item];
|
||||||
|
|
||||||
|
if (task.state === "running") {
|
||||||
|
// if the running worker isn't completed and wait to be called again
|
||||||
|
// (on worker completion)
|
||||||
|
if (!task.completedWorkers?.includes(task.runningWorker)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if all workers are completed, then return the final file and go to next task
|
||||||
|
if (task.completedWorkers.length === task.pipeline.length) {
|
||||||
|
const finalFile = task.pipelineResults?.pop();
|
||||||
|
if (finalFile) {
|
||||||
|
itemDone(task.id, finalFile);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
itemError(task.id, task.runningWorker, "no final file");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if current worker is completed, but there are more workers,
|
||||||
|
// then start the next one and wait to be called again
|
||||||
|
for (let i = 0; i < task.pipeline.length; i++) {
|
||||||
|
if (!task.completedWorkers.includes(task.pipeline[i].workerId)) {
|
||||||
|
startPipeline(task.pipeline[i]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// start the nearest waiting task and wait to be called again
|
||||||
|
if (task.state === "waiting" && task.pipeline.length > 0) {
|
||||||
|
startPipeline(task.pipeline[0]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -2,34 +2,40 @@ import { defaultLocale } from "$lib/i18n/translations";
|
|||||||
import type { CobaltSettings } from "$lib/types/settings";
|
import type { CobaltSettings } from "$lib/types/settings";
|
||||||
|
|
||||||
const defaultSettings: CobaltSettings = {
|
const defaultSettings: CobaltSettings = {
|
||||||
schemaVersion: 4,
|
schemaVersion: 5,
|
||||||
advanced: {
|
advanced: {
|
||||||
debug: false,
|
debug: false,
|
||||||
|
useWebCodecs: false,
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
theme: "auto",
|
theme: "auto",
|
||||||
language: defaultLocale,
|
language: defaultLocale,
|
||||||
autoLanguage: true,
|
autoLanguage: true,
|
||||||
|
},
|
||||||
|
accessibility: {
|
||||||
reduceMotion: false,
|
reduceMotion: false,
|
||||||
reduceTransparency: false,
|
reduceTransparency: false,
|
||||||
|
disableHaptics: false,
|
||||||
|
dontAutoOpenQueue: false,
|
||||||
},
|
},
|
||||||
save: {
|
save: {
|
||||||
|
alwaysProxy: false,
|
||||||
|
localProcessing: false,
|
||||||
audioBitrate: "128",
|
audioBitrate: "128",
|
||||||
audioFormat: "mp3",
|
audioFormat: "mp3",
|
||||||
disableMetadata: false,
|
disableMetadata: false,
|
||||||
downloadMode: "auto",
|
downloadMode: "auto",
|
||||||
filenameStyle: "classic",
|
filenameStyle: "classic",
|
||||||
savingMethod: "download",
|
savingMethod: "download",
|
||||||
tiktokH265: false,
|
allowH265: false,
|
||||||
tiktokFullAudio: false,
|
tiktokFullAudio: false,
|
||||||
twitterGif: true,
|
convertGif: true,
|
||||||
videoQuality: "1080",
|
videoQuality: "1080",
|
||||||
youtubeVideoCodec: "h264",
|
youtubeVideoCodec: "h264",
|
||||||
youtubeDubLang: "original",
|
youtubeDubLang: "original",
|
||||||
youtubeHLS: false,
|
youtubeHLS: false,
|
||||||
},
|
},
|
||||||
privacy: {
|
privacy: {
|
||||||
alwaysProxy: false,
|
|
||||||
disableAnalytics: false,
|
disableAnalytics: false,
|
||||||
},
|
},
|
||||||
processing: {
|
processing: {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
|
import defaults from "$lib/settings/defaults";
|
||||||
import type { CobaltSettings } from "$lib/types/settings";
|
import type { CobaltSettings } from "$lib/types/settings";
|
||||||
import defaults from "./defaults";
|
|
||||||
|
|
||||||
export default function lazySettingGetter(settings: CobaltSettings) {
|
export default function lazySettingGetter(settings: CobaltSettings) {
|
||||||
// Returns the setting value only if it differs from the default.
|
// Returns the setting value only if it differs from the default.
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import type { RecursivePartial } from "$lib/types/generic";
|
import type { RecursivePartial } from "$lib/types/generic";
|
||||||
import type {
|
import type {
|
||||||
|
PartialSettings,
|
||||||
AllPartialSettingsWithSchema,
|
AllPartialSettingsWithSchema,
|
||||||
CobaltSettingsV3,
|
CobaltSettingsV3,
|
||||||
CobaltSettingsV4,
|
CobaltSettingsV4,
|
||||||
PartialSettings,
|
CobaltSettingsV5,
|
||||||
} from "$lib/types/settings";
|
} from "$lib/types/settings";
|
||||||
import { getBrowserLanguage } from "$lib/settings/youtube-lang";
|
import { getBrowserLanguage } from "$lib/settings/youtube-lang";
|
||||||
|
|
||||||
@ -40,6 +41,42 @@ const migrations: Record<number, Migrator> = {
|
|||||||
|
|
||||||
return out as AllPartialSettingsWithSchema;
|
return out as AllPartialSettingsWithSchema;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
[5]: (settings: AllPartialSettingsWithSchema) => {
|
||||||
|
const out = settings as RecursivePartial<CobaltSettingsV5>;
|
||||||
|
out.schemaVersion = 5;
|
||||||
|
|
||||||
|
if (settings?.save) {
|
||||||
|
if ("tiktokH265" in settings.save) {
|
||||||
|
out.save!.allowH265 = settings.save.tiktokH265;
|
||||||
|
delete settings.save.tiktokH265;
|
||||||
|
}
|
||||||
|
if ("twitterGif" in settings.save) {
|
||||||
|
out.save!.convertGif = settings.save.twitterGif;
|
||||||
|
delete settings.save.twitterGif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings?.privacy) {
|
||||||
|
if ("alwaysProxy" in settings.privacy) {
|
||||||
|
out.save!.alwaysProxy = settings.privacy.alwaysProxy;
|
||||||
|
delete settings.privacy.alwaysProxy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings?.appearance) {
|
||||||
|
if ("reduceMotion" in settings.appearance) {
|
||||||
|
out.accessibility!.reduceMotion = settings.appearance.reduceMotion;
|
||||||
|
delete settings.appearance.reduceMotion;
|
||||||
|
}
|
||||||
|
if ("reduceTransparency" in settings.appearance) {
|
||||||
|
out.accessibility!.reduceTransparency = settings.appearance.reduceTransparency;
|
||||||
|
delete settings.appearance.reduceTransparency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out as AllPartialSettingsWithSchema;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const migrate = (settings: AllPartialSettingsWithSchema): PartialSettings => {
|
export const migrate = (settings: AllPartialSettingsWithSchema): PartialSettings => {
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
|
import type { CobaltDownloadButtonState } from "$lib/types/omnibox";
|
||||||
|
|
||||||
export const link = writable("");
|
export const link = writable("");
|
||||||
|
export const downloadButtonState = writable<CobaltDownloadButtonState>("idle");
|
||||||
|
40
web/src/lib/state/queen-bee/current-tasks.ts
Normal file
40
web/src/lib/state/queen-bee/current-tasks.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { readable, type Updater } from "svelte/store";
|
||||||
|
|
||||||
|
import type { CobaltWorkerProgress } from "$lib/types/workers";
|
||||||
|
import type { CobaltCurrentTasks, CobaltCurrentTaskItem } from "$lib/types/queen-bee";
|
||||||
|
|
||||||
|
let update: (_: Updater<CobaltCurrentTasks>) => void;
|
||||||
|
|
||||||
|
const currentTasks = readable<CobaltCurrentTasks>(
|
||||||
|
{},
|
||||||
|
(_, _update) => { update = _update }
|
||||||
|
);
|
||||||
|
|
||||||
|
export function addWorkerToQueue(workerId: string, item: CobaltCurrentTaskItem) {
|
||||||
|
update(tasks => {
|
||||||
|
tasks[workerId] = item;
|
||||||
|
return tasks;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeWorkerFromQueue(id: string) {
|
||||||
|
update(tasks => {
|
||||||
|
delete tasks[id];
|
||||||
|
return tasks;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWorkerProgress(workerId: string, progress: CobaltWorkerProgress) {
|
||||||
|
update(allTasks => {
|
||||||
|
allTasks[workerId].progress = progress;
|
||||||
|
return allTasks;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCurrentTasks() {
|
||||||
|
update(() => {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { currentTasks };
|
128
web/src/lib/state/queen-bee/queue.ts
Normal file
128
web/src/lib/state/queen-bee/queue.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { readable, type Updater } from "svelte/store";
|
||||||
|
|
||||||
|
import { checkTasks } from "$lib/queen-bee/scheduler";
|
||||||
|
import { clearFileStorage, removeFromFileStorage } from "$lib/storage";
|
||||||
|
import { clearCurrentTasks, removeWorkerFromQueue } from "$lib/state/queen-bee/current-tasks";
|
||||||
|
|
||||||
|
import type { CobaltFileReference } from "$lib/types/storage";
|
||||||
|
import type { CobaltQueue, CobaltQueueItem } from "$lib/types/queue";
|
||||||
|
|
||||||
|
const clearPipelineCache = (queueItem: CobaltQueueItem) => {
|
||||||
|
if (queueItem.state === "running" && queueItem.pipelineResults) {
|
||||||
|
for (const item of queueItem.pipelineResults) {
|
||||||
|
removeFromFileStorage(item.file.name);
|
||||||
|
}
|
||||||
|
delete queueItem.pipelineResults;
|
||||||
|
}
|
||||||
|
if (queueItem.state === "done") {
|
||||||
|
removeFromFileStorage(queueItem.resultFile.file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return queueItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
let update: (_: Updater<CobaltQueue>) => void;
|
||||||
|
|
||||||
|
const queue = readable<CobaltQueue>(
|
||||||
|
{},
|
||||||
|
(_, _update) => { update = _update }
|
||||||
|
);
|
||||||
|
|
||||||
|
export function addItem(item: CobaltQueueItem) {
|
||||||
|
update(queueData => {
|
||||||
|
queueData[item.id] = item;
|
||||||
|
return queueData;
|
||||||
|
});
|
||||||
|
|
||||||
|
checkTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemError(id: string, workerId: string, error: string) {
|
||||||
|
update(queueData => {
|
||||||
|
if (queueData[id]) {
|
||||||
|
queueData[id] = clearPipelineCache(queueData[id]);
|
||||||
|
|
||||||
|
queueData[id] = {
|
||||||
|
...queueData[id],
|
||||||
|
state: "error",
|
||||||
|
errorCode: error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return queueData;
|
||||||
|
});
|
||||||
|
|
||||||
|
removeWorkerFromQueue(workerId);
|
||||||
|
checkTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemDone(id: string, file: CobaltFileReference) {
|
||||||
|
update(queueData => {
|
||||||
|
if (queueData[id]) {
|
||||||
|
queueData[id] = clearPipelineCache(queueData[id]);
|
||||||
|
|
||||||
|
queueData[id] = {
|
||||||
|
...queueData[id],
|
||||||
|
state: "done",
|
||||||
|
resultFile: file,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return queueData;
|
||||||
|
});
|
||||||
|
|
||||||
|
checkTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pipelineTaskDone(id: string, workerId: string, file: CobaltFileReference) {
|
||||||
|
update(queueData => {
|
||||||
|
if (queueData[id] && queueData[id].state === "running") {
|
||||||
|
queueData[id].pipelineResults = [...queueData[id].pipelineResults || [], file];
|
||||||
|
queueData[id].completedWorkers = [...queueData[id].completedWorkers || [], workerId];
|
||||||
|
}
|
||||||
|
return queueData;
|
||||||
|
});
|
||||||
|
|
||||||
|
removeWorkerFromQueue(workerId);
|
||||||
|
checkTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemRunning(id: string, workerId: string) {
|
||||||
|
update(queueData => {
|
||||||
|
if (queueData[id]) {
|
||||||
|
queueData[id] = {
|
||||||
|
...queueData[id],
|
||||||
|
state: "running",
|
||||||
|
runningWorker: workerId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return queueData;
|
||||||
|
});
|
||||||
|
|
||||||
|
checkTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeItem(id: string) {
|
||||||
|
update(queueData => {
|
||||||
|
if (queueData[id].pipeline) {
|
||||||
|
for (const worker in queueData[id].pipeline) {
|
||||||
|
removeWorkerFromQueue(queueData[id].pipeline[worker].workerId);
|
||||||
|
}
|
||||||
|
clearPipelineCache(queueData[id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete queueData[id];
|
||||||
|
return queueData;
|
||||||
|
});
|
||||||
|
|
||||||
|
checkTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearQueue() {
|
||||||
|
update(() => {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
clearCurrentTasks();
|
||||||
|
clearFileStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { queue };
|
11
web/src/lib/state/queue-visibility.ts
Normal file
11
web/src/lib/state/queue-visibility.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import settings from "$lib/state/settings";
|
||||||
|
import { get, writable } from "svelte/store";
|
||||||
|
|
||||||
|
export const queueVisible = writable(false);
|
||||||
|
|
||||||
|
export const openQueuePopover = () => {
|
||||||
|
const visible = get(queueVisible);
|
||||||
|
if (!visible && !get(settings).accessibility.dontAutoOpenQueue) {
|
||||||
|
return queueVisible.update(v => !v);
|
||||||
|
}
|
||||||
|
}
|
81
web/src/lib/storage.ts
Normal file
81
web/src/lib/storage.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
const cobaltProcessingDir = "cobalt-processing-data";
|
||||||
|
|
||||||
|
export class OPFSStorage {
|
||||||
|
#root;
|
||||||
|
#handle;
|
||||||
|
#io;
|
||||||
|
|
||||||
|
constructor(root: FileSystemDirectoryHandle, handle: FileSystemFileHandle, reader: FileSystemSyncAccessHandle) {
|
||||||
|
this.#root = root;
|
||||||
|
this.#handle = handle;
|
||||||
|
this.#io = reader;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async init() {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
const cobaltDir = await root.getDirectoryHandle(cobaltProcessingDir, { create: true });
|
||||||
|
const handle = await cobaltDir.getFileHandle(crypto.randomUUID(), { create: true });
|
||||||
|
const reader = await handle.createSyncAccessHandle();
|
||||||
|
|
||||||
|
return new this(cobaltDir, handle, reader);
|
||||||
|
}
|
||||||
|
|
||||||
|
async res() {
|
||||||
|
// await for compat with ios 15
|
||||||
|
await this.#io.flush();
|
||||||
|
await this.#io.close();
|
||||||
|
return await this.#handle.getFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
read(size: number, offset: number) {
|
||||||
|
const out = new Uint8Array(size);
|
||||||
|
const bytesRead = this.#io.read(out, { at: offset });
|
||||||
|
|
||||||
|
return out.subarray(0, bytesRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(data: Uint8Array | Int8Array, offset: number) {
|
||||||
|
return this.#io.write(data, { at: offset })
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy() {
|
||||||
|
await this.#root.removeEntry(this.#handle.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
static isAvailable() {
|
||||||
|
return !!navigator.storage?.getDirectory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeFromFileStorage = async (filename: string) => {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
const cobaltDir = await root.getDirectoryHandle(cobaltProcessingDir);
|
||||||
|
return await cobaltDir.removeEntry(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearFileStorage = async () => {
|
||||||
|
if (navigator.storage.getDirectory) {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
try {
|
||||||
|
await root.removeEntry(cobaltProcessingDir, { recursive: true });
|
||||||
|
} catch {
|
||||||
|
// ignore the error because the dir might be missing and that's okay!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearCacheStorage = async () => {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
caches.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getStorageQuota = async () => {
|
||||||
|
let estimate;
|
||||||
|
if (navigator.storage.estimate) {
|
||||||
|
estimate = await navigator.storage.estimate();
|
||||||
|
}
|
||||||
|
return estimate;
|
||||||
|
}
|
@ -1,8 +1,11 @@
|
|||||||
|
import type { CobaltSettings } from "$lib/types/settings";
|
||||||
|
|
||||||
enum CobaltResponseType {
|
enum CobaltResponseType {
|
||||||
Error = 'error',
|
Error = 'error',
|
||||||
Picker = 'picker',
|
Picker = 'picker',
|
||||||
Redirect = 'redirect',
|
Redirect = 'redirect',
|
||||||
Tunnel = 'tunnel',
|
Tunnel = 'tunnel',
|
||||||
|
LocalProcessing = 'local-processing',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CobaltErrorResponse = {
|
export type CobaltErrorResponse = {
|
||||||
@ -40,6 +43,36 @@ type CobaltTunnelResponse = {
|
|||||||
status: CobaltResponseType.Tunnel,
|
status: CobaltResponseType.Tunnel,
|
||||||
} & CobaltPartialURLResponse;
|
} & CobaltPartialURLResponse;
|
||||||
|
|
||||||
|
export type CobaltLocalProcessingResponse = {
|
||||||
|
status: CobaltResponseType.LocalProcessing,
|
||||||
|
|
||||||
|
// TODO: proper type for processing types
|
||||||
|
type: string,
|
||||||
|
service: string,
|
||||||
|
tunnel: string[],
|
||||||
|
|
||||||
|
output: {
|
||||||
|
type: string, // mimetype
|
||||||
|
filename: string,
|
||||||
|
metadata?: {
|
||||||
|
album?: string,
|
||||||
|
copyright?: string,
|
||||||
|
title?: string,
|
||||||
|
artist?: string,
|
||||||
|
track?: string,
|
||||||
|
date?: string
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
audio?: {
|
||||||
|
copy: boolean,
|
||||||
|
format: string,
|
||||||
|
bitrate: string,
|
||||||
|
},
|
||||||
|
|
||||||
|
isHLS?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
export type CobaltFileUrlType = "redirect" | "tunnel";
|
export type CobaltFileUrlType = "redirect" | "tunnel";
|
||||||
|
|
||||||
export type CobaltSession = {
|
export type CobaltSession = {
|
||||||
@ -63,10 +96,17 @@ export type CobaltServerInfo = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: strict partial
|
||||||
|
// this allows for extra properties, which is not ideal,
|
||||||
|
// but i couldn't figure out how to make a strict partial :(
|
||||||
|
export type CobaltSaveRequestBody =
|
||||||
|
{ url: string } & Partial<Omit<CobaltSettings['save'], 'savingMethod'>>;
|
||||||
|
|
||||||
export type CobaltSessionResponse = CobaltSession | CobaltErrorResponse;
|
export type CobaltSessionResponse = CobaltSession | CobaltErrorResponse;
|
||||||
export type CobaltServerInfoResponse = CobaltServerInfo | CobaltErrorResponse;
|
export type CobaltServerInfoResponse = CobaltServerInfo | CobaltErrorResponse;
|
||||||
|
|
||||||
export type CobaltAPIResponse = CobaltErrorResponse
|
export type CobaltAPIResponse = CobaltErrorResponse
|
||||||
| CobaltPickerResponse
|
| CobaltPickerResponse
|
||||||
| CobaltRedirectResponse
|
| CobaltRedirectResponse
|
||||||
| CobaltTunnelResponse;
|
| CobaltTunnelResponse
|
||||||
|
| CobaltLocalProcessingResponse;
|
||||||
|
@ -1,18 +1,16 @@
|
|||||||
export type InputFileKind = "video" | "audio";
|
import type { CobaltFileReference } from "$lib/types/storage";
|
||||||
|
|
||||||
export type FileInfo = {
|
export type FileInfo = {
|
||||||
type?: string | null,
|
type?: string,
|
||||||
kind: InputFileKind,
|
format?: string,
|
||||||
extension: string,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RenderParams = {
|
export type RenderParams = {
|
||||||
blob: Blob,
|
files: CobaltFileReference[],
|
||||||
output?: FileInfo,
|
output: FileInfo,
|
||||||
args: string[],
|
args: string[],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export type FFmpegProgressStatus = "continue" | "end" | "unknown";
|
export type FFmpegProgressStatus = "continue" | "end" | "unknown";
|
||||||
export type FFmpegProgressEvent = {
|
export type FFmpegProgressEvent = {
|
||||||
status: FFmpegProgressStatus,
|
status: FFmpegProgressStatus,
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user