2093 Commits
7 ... develop

Author SHA1 Message Date
jj
91989b8de1 youtube: await decipher()s
Some checks failed
CodeQL / Analyze (javascript-typescript) (pull_request) Failing after 13s
Run service tests / test service functionality (pull_request) Successful in 23s
Run tests / check lockfile correctness (pull_request) Successful in 20s
Run tests / web sanity check (pull_request) Successful in 1m25s
Run tests / api sanity check (pull_request) Failing after 27s
Run service tests / test service: ${{ matrix.service }} (pull_request) Failing after 20s
they are async as of https://github.com/LuanRT/YouTube.js/pull/1047
2025-10-13 23:30:58 +00:00
jj
1dc0aa89df youtube: provide own JS interpreter
since the data should be side-effect free, this should
be fine for now, but maybe it might be a good idea to look
into a proper sandboxed environment in the future.
2025-10-13 23:30:58 +00:00
jj
c86ab40acd youtube: fix custom fetch breakage
https://github.com/LuanRT/YouTube.js/issues/962#issuecomment-2864091135
2025-10-13 23:30:58 +00:00
jj
dad8bf8baa api/youtube: try new session server 2025-10-13 22:52:42 +00:00
jj
3e25b91b67 api/package: bump youtubei.js to 16.0.0 2025-10-13 22:19:46 +00:00
jj
47d8ccbc17 api/package: bump to 11.5 2025-09-24 16:29:17 +00:00
jj
fc76e24996 api/package: bump youtubei.js to 15.1.1 2025-09-24 16:29:03 +00:00
zImPatrick
83a8da7151 api/vimeo: add support for specifying access tokens (#1443)
* the android client now also needs a valid device_identifier, but already generated access_tokens work fine
* you can also get them from a rooted device by mitm'ing the device while starting vimeo for the first time or going into its data directory and searching for the token in shared_prefs/
2025-09-24 18:27:13 +02:00
jj
d577d5e451 api/package: bump version to 11.4.3 2025-09-05 19:44:26 +00:00
jj
bec77b99e5 api/twitter: use newer graphql endpoint, refactor (#1436) 2025-09-05 19:00:50 +02:00
wukko
990ce9a4d2 readme: add info about the warp sponsor
it's only present on github, which is cool cuz we get to keep cobalt itself ad-free
2025-08-29 09:31:19 +06:00
wukko
29deb4dccb api/package: bump version to 11.4.2 2025-08-19 14:05:06 +06:00
wukko
7a6977ec35 api/processing/service-patterns: refactor
sorted all patterns alphabetically and moved the "or" operator to the end of the line
2025-08-11 18:31:52 +06:00
wukko
64a7b1dd62 api/bilibili: add support for video parts/episodes
different videos share the same id, kind of weird
2025-08-11 18:15:38 +06:00
TfourJ
1d5db46a79 api/service-config: add support for m subdomain for twitch (#1407)
* api/twitch: add support for m. subdomain

* tests/twitch: add test for m. subdomain
2025-08-10 13:14:23 +02:00
jj
d3ea80fdfe docs/run-an-instance: replace useless digitalocean links 2025-08-06 21:09:15 +00:00
jj
ea3dc7e6a8 ci: split up install/run steps 2025-07-23 17:11:12 +00:00
jj
b1226ab371 ci: replace API_EXTERNAL_PROXY env with HTTP_PROXY 2025-07-23 17:11:12 +00:00
wukko
b376865a13 api/package: bump version to 11.4.1 2025-07-23 19:53:25 +06:00
wukko
3e41cb7022 api: update youtubei.js to 15.0.1 2025-07-23 19:46:55 +06:00
wukko
10af362fe8 api/env: add a warning about deprecation of API_EXTERNAL_PROXY 2025-07-23 19:42:30 +06:00
wukko
9fc5370b03 api/package: bump version to 11.4 2025-07-20 22:53:48 +06:00
jj
09752057fe docs/api-env: remove note about proxy/env file 2025-07-19 16:06:28 +00:00
jj
00b2a8f085 api/env: update proxy envs when changed in env file 2025-07-19 16:05:38 +00:00
jj
3c5f5b90b2 api/env: refactor subscribe event logic 2025-07-19 15:47:37 +00:00
jj
09c42d9be0 api/core: update dispatcher when proxy is changed 2025-07-19 15:24:13 +00:00
jj
5908e9da15 api/env: add subscribe() for dynamic reloads 2025-07-19 15:21:57 +00:00
wukko
c8b2fe44c8 docs/api-env-variables: add info about undici proxy variables
and add info about deprecation of API_EXTERNAL_PROXY
2025-07-19 20:57:10 +06:00
wukko
e83baa9138 api/core: use EnvHttpProxyAgent for proxy requests 2025-07-19 20:43:29 +06:00
wukko
95efd71eac api: update youtubei.js to 15.0.0, use main package again
undici update was merged upstream :D
2025-07-19 18:47:07 +06:00
KwiatekMiki
3f785e7cbe api: support new xiaohongshu links, add fallbacks to getRedirectingURL
closes #1394
Co-authored-by: wukko <me@wukko.me>
2025-07-18 20:43:58 +06:00
wukko
b9042a94e9 api/tests/soundcloud: allow go+ test to fail 2025-07-18 20:31:44 +06:00
wukko
71205791dd api/package: update @imput/youtubei.js 2025-07-18 15:08:15 +06:00
wukko
63ee694d36 api/package: bump version to 11.3.1 2025-07-12 23:07:18 +06:00
jj
60f02b18e4 vimeo: use android client for session 2025-07-11 18:35:02 +00:00
wukko
e4b53880af web/package: bump version to 11.3 2025-07-10 19:07:03 +06:00
wukko
2425f18908 api/package: bump version to 11.3 2025-07-10 19:06:52 +06:00
wukko
02386544f3 api/stream/shared: add tiktok headers
referer is now required to access video links
2025-07-10 18:19:18 +06:00
wukko
61de303dc4 api: add support for newgrounds
closes #620, replaces #1368
Co-authored-by: hyperdefined <contact@hyper.lol>
2025-07-10 00:56:35 +06:00
wukko
172fb4c561 web/i18n/save: improve clarity of the services disclaimer 2025-07-09 16:23:12 +06:00
wukko
8353bd2075 web/package: bump version to 11.2.4 2025-07-09 16:14:03 +06:00
wukko
1a499238aa api/package: bump version to 11.2.3 2025-07-09 16:13:53 +06:00
wukko
58ea4aed01 api/soundcloud: check if a cover url returns 200
some songs don't have a cover but artwork_url is still defined, even though the response is always 404
2025-07-09 15:56:22 +06:00
wukko
e8113a83de web/changelog/11.2: add info about vk download speed 2025-07-08 21:22:45 +06:00
wukko
94a8eab5e0 web/i18n/save: rephrase the disclaimer to cover our ass more 2025-07-08 21:20:13 +06:00
wukko
51a9680b39 api/match: fix localDisabled
accidentally left the old naming of the option here, typescript would've prevented this
2025-07-08 20:58:19 +06:00
wukko
40da8a46d6 api/config: update chromium version in generic user agent 2025-07-07 20:09:02 +06:00
wukko
2e86a6ca70 api/bilibili: don't return isHLS
videos are no longer HLS i guess
2025-07-07 18:08:26 +06:00
wukko
43f4793448 web/i18n/ru: rephrase some strings 2025-07-06 17:37:51 +06:00
wukko
2ac0436f71 api/tiktok: return empty error if there's nothing to download
sometimes posts are broken and don't have any valid media
2025-07-06 00:04:40 +06:00
wukko
14b9a590d9 api/package: bump version to 11.2.2 2025-07-04 15:58:57 +06:00
wukko
b4290ecf30 api/vimeo: use bearer, update headers, better error handling 2025-07-04 15:55:40 +06:00
wukko
773ed026b8 web/package: bump version to 11.2.3 2025-07-04 13:57:17 +06:00
wukko
926e9b7231 api/package: bump version to 11.2.1 2025-07-04 13:57:10 +06:00
wukko
23064f8300 web/changelogs/11.2: add info about youtube's unavailability 2025-07-04 13:55:26 +06:00
wukko
f3992fbe33 api/language-codes: prevent errors if code is undefined 2025-07-01 16:32:39 +06:00
wukko
810e0a865c api/package: replace youtubei.js with a fork, update undici 2025-07-01 16:15:12 +06:00
wukko
5b12622b66 web/FilenamePreview: fix unlocalized strings
oops
2025-07-01 10:13:28 +06:00
wukko
9b3ebe90c5 api/language-codes: remove region part of the language code
and convert language codes if they're not 3 characters long
2025-07-01 01:06:31 +06:00
wukko
4d2c8b0a8c web/package: bump version to 11.2.2 2025-07-01 00:19:29 +06:00
imput project translators
0cb64dd3f9 web/i18n/ru: add russian translation
Co-authored-by: wukko <me@wukko.me>
Co-authored-by: Damir Modyarov <otomir@yandex.ru>
Co-authored-by: 71d1k <71d1k@users.noreply.github.com>
Co-authored-by: Alexey Muravyev <teosverdi@users.noreply.github.com>
Co-authored-by: GreenMonster362905 <greenmonster362905@users.noreply.github.com>
Co-authored-by: Ilya <wileyfoxyx@users.noreply.github.com>
Co-authored-by: Kurt <kkhaustov@users.noreply.github.com>
Co-authored-by: Nikita <50026919+nubovik01@users.noreply.github.com>
Co-authored-by: Philipp <FoxFil@users.noreply.github.com>
Co-authored-by: Soroka <isorokai@users.noreply.github.com>
Co-authored-by: aksephi <aksephi@users.noreply.github.com>
Co-authored-by: azy61b <azy61b@users.noreply.github.com>
Co-authored-by: ilia-21 <ilia-21@users.noreply.github.com>
Co-authored-by: imput project translators <i18n@imput.net>
Co-authored-by: jj <log@riseup.net>
Co-authored-by: v1s7 <v1s7@users.noreply.github.com>
2025-07-01 00:09:11 +06:00
wukko
33f2c4e174 web/changelogs: add 11.2 changelog 2025-07-01 00:03:11 +06:00
wukko
0a069c875d docs/api: specify that ISO 639-1 language code is expected 2025-06-30 18:42:23 +06:00
wukko
7aa128d9cc web/package: bump version to 11.2.1 2025-06-29 18:11:09 +06:00
wukko
0ac42d5b9d web/ffmpeg: define multithreading support outside of web worker context
there's no navigator.maxTouchPoints in web worker context, so previously there was no way to detect whether safari is running on ipad or not
2025-06-29 18:08:50 +06:00
wukko
b2c5c42ae3 web/device: add supports.multithreading 2025-06-29 13:42:01 +06:00
wukko
d25a730768 web/device: enable local processing everywhere but android chrome 2025-06-29 13:41:42 +06:00
wukko
aa49892e39 web: update ios safari version regex
since ipados pretends to be macos, there's no "iphone os" in its user agent. this (hopefully) fixes remuxing/transcoding compatibility with old ipados versions
2025-06-29 10:53:02 +06:00
wukko
214af73a1e docs/api: add subtitleLang, sublanguage, and update localProcessing 2025-06-28 21:50:43 +06:00
wukko
a60e94d628 web/package: bump version to 11.2 2025-06-28 20:47:14 +06:00
wukko
8da71e413e api/package: bump version to 11.2 2025-06-28 20:47:08 +06:00
wukko
a751f81ea3 version-info: return git branch info correctly in cf workers 2025-06-28 19:06:21 +06:00
wukko
bd0caac5ba web/changelogs/11.0: set a fixed commit in compare, fix env name error 2025-06-28 17:48:31 +06:00
wukko
4fc2952c54 web/audio-sub-language: update localized values dynamically 2025-06-28 17:43:46 +06:00
wukko
d70180b23c api/core: merge isApiKey and isSession into authType
cuz they can't be true at the same time
2025-06-28 17:05:18 +06:00
wukko
bc8c16f469 web/env: accept 1 as bool value 2025-06-28 16:59:00 +06:00
wukko
3d2473d8ef web/audio-sub-language: refactor to avoid code duplication 2025-06-28 16:44:28 +06:00
wukko
c16444126e api/env: backwards compatibility with SESSION_RATELIMIT 2025-06-28 16:37:54 +06:00
wukko
7298082bd5 api: refactor two static arrays to set 2025-06-28 16:31:39 +06:00
wukko
9d818300f4 api/twitter: add subtitle extraction
closes #1219
2025-06-28 16:19:41 +06:00
wukko
fffb31dbf0 web/i18n/error/api: fix a typo in fetch.short_link 2025-06-28 14:46:03 +06:00
wukko
51c5d055ec api/service-patterns/tiktok: allow longer shortLink
tiktok is a/b testing a new shortLink format that's ±19 characters long but behaves the same way as old format
2025-06-28 14:45:04 +06:00
wukko
900c6f27ca api/tests/vimeo: allow mature video tests to fail 2025-06-27 21:47:21 +06:00
wukko
8feaf5c636 api/api-keys: replace .find() with .some() in allowedServices
& also a little refactor
2025-06-26 22:32:39 +06:00
wukko
dce9eb30c1 docs/protect-an-instance: add info about allowedServices in api keys 2025-06-26 22:23:25 +06:00
wukko
3243564f77 api/api-keys: add allowedServices to limit or extend access
it's useful for limiting access to services per key, or for overriding default list of enabled services with "all"
2025-06-26 22:20:09 +06:00
wukko
d69100c68d api/tiktok: validate that redirected link is still tiktok 2025-06-26 21:32:31 +06:00
wukko
81a0d5e154 web/queue: scale cropped covers to 720x720
instead of 800x800 because usually thumbnails that need to be cropped are 1280x720
2025-06-26 18:11:02 +06:00
wukko
bfb23c86f9 web/queue: add cover only to mp3 files 2025-06-26 18:09:04 +06:00
wukko
84aa80e2d3 api/match-action: don't add cover if metadata is disabled 2025-06-26 17:45:01 +06:00
wukko
655e7a53a2 docs/api: add info about cover & cropCover 2025-06-26 17:39:05 +06:00
wukko
e4ce873b56 web/queue: add audio covers & crop them when needed 2025-06-26 17:36:55 +06:00
wukko
164ea8aeb9 api: return covers from soundcloud and youtube
& refactor createProxyTunnels() in stream/manage a little
2025-06-26 17:36:26 +06:00
wukko
4ff4766bda docs/api: add info about subtitle bool in local processing response 2025-06-26 15:59:38 +06:00
wukko
f7e5951410 web/lib/device: enable local processing on all ios devices 2025-06-25 23:19:24 +06:00
wukko
f4637b746c api/rutube: add subtitles 2025-06-25 20:12:30 +06:00
wukko
3dae5b2eb0 api/ffmpeg: move stream type + url count check to remux()
& fix it cuz i broke it in last commit
2025-06-25 19:57:23 +06:00
wukko
52695cbd0f api/service-config: replace static arrays with sets 2025-06-25 19:33:16 +06:00
wukko
fcdf5da73e api/ffmpeg: refactor even more 2025-06-25 19:32:36 +06:00
wukko
d3793c7a54 api/ffmpeg: map video and audio in remux() with one main input
cuz otherwise if a video has subtitles, then only subtitles get mapped to the output file
2025-06-24 20:46:14 +06:00
wukko
4f4478a21d api/ffmpeg: fix audio codec args in remux() 2025-06-24 20:24:53 +06:00
wukko
14657e51d3 api/stream: split types.js into proxy.js and ffmpeg.js 2025-06-24 20:09:41 +06:00
wukko
aa376d76f6 api/stream/types: huge refactor & simplification of code
- created render() which handles ffmpeg & piping stuff
- merged remux() and merge() into one function
- simplified and cleaned up arguments
- removed headers since they're handled by internal streams now
- removed outdated arguments
2025-06-24 19:55:50 +06:00
wukko
28b85380c9 api/vk: allow auto generated subs & pick explicitly vtt
i couldn't find a single video that had any subtitles other than auto generated ones, so i think this is better than nothing at all
2025-06-24 17:56:04 +06:00
wukko
75691d4bac api/tests/facebook: replace a dead link 2025-06-24 17:28:05 +06:00
wukko
ff06a10b5c api/processing/url: improve vk url parsing 2025-06-24 17:21:32 +06:00
wukko
997b06ed0e api/vk: add support for subtitles 2025-06-24 17:06:19 +06:00
wukko
44f4ea32c6 api/stream/internal: stream vk videos in chunks 2025-06-24 17:04:43 +06:00
wukko
599ec9dd92 web/UpdateNotification: update margin & font size
this also fixes position in rtl layout
2025-06-22 20:56:05 +06:00
wukko
b384dc81cd web/error/api: add missing "the" to fetch.critical.core 2025-06-22 20:12:36 +06:00
wukko
6d62bce92d api/match-action: don't force local-processing response for pickers
cuz that won't work, at least for now
2025-06-22 20:12:22 +06:00
wukko
21c4a1ebbc api/match: set alwaysProxy to true if local processing is forced 2025-06-22 20:09:48 +06:00
wukko
0fca9c440c api/schema: remove deprecated variables 2025-06-22 20:07:37 +06:00
wukko
05fb1601c8 api/match: update forcing local processing via env 2025-06-22 20:06:28 +06:00
wukko
f883887e4a web/queue: don't try to add a remux task if response type is proxy 2025-06-22 16:33:00 +06:00
wukko
61e0862b10 web/types/api: add proxy local processing type 2025-06-22 16:31:09 +06:00
wukko
885398955f web/settings/local: transform the media processing setting to a switcher 2025-06-22 16:29:47 +06:00
wukko
a4d5f5b380 web/settings: migrate boolean localProcessing to enum 2025-06-22 16:28:18 +06:00
wukko
ac85ce86c0 api/processing/request: backwards compat with boolean localProcessing 2025-06-22 16:21:55 +06:00
wukko
28ab2747ce api/match-action: support forced local processing 2025-06-22 16:21:37 +06:00
wukko
a6b599a828 api/schema: transform localProcessing to enum 2025-06-22 16:20:27 +06:00
wukko
a998a5720c web/queue: refactor media icon selection 2025-06-22 15:35:04 +06:00
wukko
2c0a1b6990 web/i18n/settings: update subtitles description 2025-06-20 20:35:23 +06:00
wukko
630e4a6e0d api/tiktok: add support for subtitles 2025-06-20 20:07:50 +06:00
wukko
aff2d22edc api/language-codes: add reverse lookup (2 to 1) 2025-06-20 20:05:17 +06:00
wukko
d18b22e7ed api/processing/request: return a unique error code 2025-06-20 19:53:01 +06:00
wukko
ab526c234e api/loom: add transcription subtitles
since there's no language selection (at all), we just add the only transcription if a user wants subtitles
2025-06-20 18:59:35 +06:00
wukko
17ab8dd709 web/queue: add subtitles independently from remux type
so that you can have a mute video with subtitles
2025-06-20 18:30:39 +06:00
wukko
a44bea6b50 api/vimeo: add subtitle parsing from the mobile api 2025-06-20 18:21:00 +06:00
wukko
a5838f3c05 api/stream/types: add subtitles & metadata to remux 2025-06-20 18:16:32 +06:00
wukko
254ad961d3 web/queue: add subtitle args when output has subtitles
not when there are 3 tunnels, that was dumb of me, my bad
2025-06-20 17:41:20 +06:00
wukko
337edfc984 api/request/local-processing: return subtitles boolean 2025-06-20 17:38:49 +06:00
wukko
0b0f0d65ef web/queue: add subtitle codec args 2025-06-20 17:32:53 +06:00
wukko
96a02d554f web/package: update libav packages 2025-06-20 17:28:06 +06:00
wukko
7ce9d6882b api/youtube: don't use session if user wants subtitles
cuz they're not currently available anywhere but HLS
2025-06-20 17:27:49 +06:00
wukko
993a885a3e web/util: add support for subtitle track language metadata 2025-06-20 16:20:32 +06:00
wukko
9f7f63783d web/api/saving-handler: add youtubeVideoContainer & subtitleLang 2025-06-20 16:09:19 +06:00
wukko
a30a27a4ec web/settings/metadata: add subtitles language dropdown 2025-06-20 15:56:11 +06:00
wukko
5860c50c59 web/settings/video: add youtube container settings 2025-06-20 15:50:30 +06:00
wukko
1e5cc353e4 web/audio-sub-language: refactor, prioritize popular languages
now the page with a picker won't freeze if intl can't recognize a language code & subtitle settings item will be localized. dub & sub now use their own arrays of languages (first one needs "original" as default and second one needs "none" as default).
2025-06-20 15:42:50 +06:00
wukko
c9fdfca239 web/SettingsDropdown: prevent crash if selectedTitle is undefined 2025-06-20 15:04:37 +06:00
wukko
6e394cda29 web/settings: add youtubeVideoContainer & subtitleLang
& bump schema to v6
2025-06-20 15:00:13 +06:00
wukko
3daf1c4834 web: refactor youtube-lang
- fixed unlocalized "original" string
- added subtitle type exports
2025-06-20 14:54:42 +06:00
wukko
c4e910dd29 api/stream/types: refactor, support mkv, don't duplicate args 2025-06-20 14:39:17 +06:00
wukko
33c801f66b api/youtube: add support for youtubeVideoContainer 2025-06-20 14:35:43 +06:00
wukko
eb249a3eed api/match: ignore subtitleLang if it's "none" 2025-06-20 14:35:14 +06:00
wukko
2396462c5c api/schema: add youtubeVideoContainer 2025-06-20 14:23:16 +06:00
wukko
4da95e0a2b web/libav: disable wasm multithreading on old ios 2025-06-20 00:43:18 +06:00
wukko
fef6ee1a17 web/i18n/error/queue: add missing generic_error
oops
2025-06-19 22:38:16 +06:00
wukko
672b3dcf46 api/match-action: convert ISO 639-1 language codes to ISO 639-2 2025-06-19 17:46:15 +06:00
wukko
b91c0c0013 api/stream/types: specify subtitle format for containers other than mp4 2025-06-19 17:19:39 +06:00
wukko
259a0758f1 api: initial subtitles functionality with youtube support
this took way more effort than i expected it to do, mostly because of youtube locking everything down to shit

local processing doesn't function with subtitles yet, wasm needs to be updated
2025-06-18 20:24:13 +06:00
wukko
967552b26b api/schema: add subtitleLang 2025-06-18 18:37:24 +06:00
wukko
1e3593103b docs/api-env-variables: update FORCE_LOCAL_PROCESSING section 2025-06-17 13:40:32 +06:00
wukko
c0d3c21e75 docs/api-env-variables: add info about ENABLE_DEPRECATED_YOUTUBE_HLS 2025-06-17 13:40:17 +06:00
wukko
19be25c6e6 docs/api-env-variables: fix ratelimit variable names
closes #1354
2025-06-17 13:34:39 +06:00
wukko
b8801570a9 api/env: SESSION_RATELIMIT -> SESSION_RATELIMIT_MAX 2025-06-17 13:33:50 +06:00
wukko
af99e7218c api: disable youtube HLS by default & add env to enable it 2025-06-17 13:21:16 +06:00
wukko
1e7406de9d web/i18n/error/api: rephrase youtube.no_matching_format 2025-06-16 13:53:57 +06:00
wukko
5e7f9c53b9 api/package: update youtubei.js to 14.0.0 2025-06-16 13:53:04 +06:00
wukko
2ac9153142 web/CaptchaTooltip: increase max width
allows for prettier layout of text in languages other than english
2025-06-15 15:16:32 +06:00
wukko
e18575f78c web/i18n/error/api: update youtube.no_matching_format 2025-06-15 13:24:10 +06:00
wukko
507fab847b web/workers/ffmpeg: proper error code for missing audio channel error 2025-06-14 17:24:47 +06:00
wukko
5ea170a5ac web: deprecate youtube HLS, enable it only via env variable
it's now disabled by default because if we ever need HLS for youtube in the future, it'll be managed by the processing instance, not the web client. will probably be removed completely in next major release.
2025-06-14 16:35:35 +06:00
wukko
863d39db6f web/i18n/about: remove an unused string 2025-06-12 12:37:38 +06:00
wukko
ace654ea91 web/i18n/dialog: remove even more unused strings 2025-06-12 11:56:09 +06:00
wukko
d0298db112 web/i18n/dialog: remove unused strings 2025-06-12 11:48:29 +06:00
jj
81c8daf852 web/storage: robuster er opfs availability check 2025-06-11 14:25:16 +00:00
jj
a06baa41c1 web: add uuid() function with fallback if randomUUID is missing 2025-06-11 14:18:04 +00:00
jj
eb90843fc9 web/pagenav: use pop() instead of at(-1) 2025-06-11 14:17:32 +00:00
wukko
dbb83b9e97 web/i18n/settings: remove unused strings 2025-06-11 17:50:28 +06:00
jj
35530459b6 ci: replace WEB_DEFAULT_API var with dummy
why was this a variable wtf
2025-06-11 11:10:13 +00:00
wukko
0bcb28c44c merge: 11.1 api update 2025-06-08 20:53:33 +06:00
wukko
ed980e3893 api/package: bump version to 11.1 2025-06-08 20:52:12 +06:00
wukko
4b6447cba6 api/youtube: use the original track instead of default
closes #1329

default ≠ original, apparently. not sure why youtube thought it's a good idea to force ai generated dubs as default
2025-06-08 19:30:43 +06:00
jj
adc5b89fc2 api/soundcloud: ignore encrypted protocols, match against prefix 2025-06-08 13:17:14 +00:00
jj
2154f464d7 api/soundcloud: prefer progressive format over hls 2025-06-08 12:44:34 +00:00
wukko
eae6a7aa63 api/tests/soundcloud: update the go+ test link 2025-06-08 18:17:07 +06:00
ryaan
ab513ead4b docs/run-an-instance: fix the api path in local dev instructions (#1331) 2025-06-08 18:12:52 +06:00
hyperdefined
495729e174 api/pinterest: return fetch.empty if a link is invalid (#1299) 2025-06-08 18:11:05 +06:00
nexpid
170cf293bf api/soundcloud: add more metadata fields (#1313) 2025-06-08 18:07:38 +06:00
jj
19c036494f api/cluster: version check for supportsReusePort() 2025-06-07 09:24:33 +00:00
jj
212b07394d api: set up env watcher only after cluster is initialized 2025-06-07 09:23:09 +00:00
jj
e2b6879ea2 api/env: log information about dynamic env changes 2025-06-07 08:35:36 +00:00
wukko
5ac87bab09 web/package: bump version to 11.0.2 2025-06-07 14:04:51 +06:00
wukko
ad96155831 api/package: bump version to 11.0.3 2025-06-07 14:04:39 +06:00
NexusXe
1bd320ced4 web/i18n/remux: fix a typo in explainer's description (#1320) 2025-06-07 13:17:02 +06:00
wukko
d6095db619 api/service-config/youtube: add /v/:id link pattern
closes #1327
2025-06-07 13:12:04 +06:00
wukko
5a7708f030 docs/api: fix spacing typo in a table 2025-06-07 12:24:40 +06:00
wukko
df7819daa1 api/tests/twitter: update some test links to more popular ones
so that twitter doesn't require someone with an account to view the post before making it available for logged out access. really annoying & makes tests fail
2025-06-07 12:00:51 +06:00
wukko
10e6b4ec71 api/service-alias: add an alias for twitch
because only clips are supported for now. vods may be supported after we implement HLS local processing
2025-06-07 11:51:44 +06:00
wukko
5cd5013de0 api/service-config/vimeo: add /groups/ link pattern
closes #1324
2025-06-07 11:49:40 +06:00
wukko
77e78d55fc web/workers/fetch: catch network-related errors & retry 3 times
previously all network issues showed a "worker crashed" error, which people misinterpreted all the time, and reasonably so
2025-06-07 11:46:16 +06:00
wukko
2f5304f479 web/i18n/queue: update fetch.empty_tunnel to be more informative 2025-06-07 11:29:28 +06:00
jj
8c3c084c9c docker: bump node version to 24 2025-06-05 18:14:01 +00:00
jj
a0560fe684 web: update crypto addresses 2025-06-05 18:14:01 +00:00
wukko
291f3401dd web/queue: fix overflow scroll
oops
2025-06-05 13:45:49 +06:00
wukko
5c1a855ddf web/package: update version to 11.0.1 2025-06-03 12:25:28 +06:00
wukko
c2ff7afc6f web/about/privacy: add a link to plausible host & update cf section
cf pages -> cf workers (cuz we moved to static workers)
2025-06-03 12:07:54 +06:00
wukko
b304549a8d web/routes: refactor error & /about/[page] to svelte 5 2025-06-03 11:38:38 +06:00
wukko
58209970ac web/wrangler: add not_found_handling 2025-06-03 10:58:11 +06:00
wukko
ee2be1fb9e web/device: enable local processing on ios 18+ by default
hopefully ios users will figure out what buttons they have to press, but if not, i'll add an explanatory dialog
2025-06-03 10:49:38 +06:00
jj
3ee7c4d36a web: add cloudflare wrangler.jsonc file 2025-06-01 13:02:08 +00:00
jj
b4a53d0fde web/state/task-manager: use writable-readonly store instead of readable 2025-06-01 10:11:40 +00:00
jj
7f5a9cfa75 api/config: remove unused cluster import 2025-05-31 13:51:57 +00:00
jj
a7bf5c525d api/package: bump version to 11.0.2 2025-05-31 13:49:15 +00:00
jj
57eba51959 api/env: broadcast raw contents instead of parsed 2025-05-31 13:43:30 +00:00
wukko
7fa3340a13 web/layout: prevent preload component from being visible
oops
2025-05-31 18:27:50 +06:00
wukko
ea3223e0b0 web/ProcessingQueueItem: fix unintentional overflow
this resulted the queue to be scrollable horizontally when multiple items are present. caused by the button reveal animation. only affected desktop layouts
2025-05-30 13:19:26 +06:00
jj
d6e2f3cb12 web/storage: more stringent opfs check 2025-05-29 19:31:01 +00:00
jj
1c304457e2 api/package: bump version to 11.0.1 2025-05-29 17:22:03 +00:00
jj
ed18008493 api/soundcloud: return isHLS flag when appropriate 2025-05-29 17:08:22 +00:00
wukko
a52dde7654 merge: cobalt 11 with local processing & better performance (#1287) 2025-05-29 22:23:48 +06:00
wukko
3142b49d2d web/changelogs/11.0: add more info about loom changes 2025-05-29 21:34:42 +06:00
jj
ff7eb2639d api/loom: add support for non-transcoded links, add more tests 2025-05-29 15:29:53 +00:00
wukko
8a7b3e9386 docs/examples/docker-compose: update cobalt package version 2025-05-29 20:54:31 +06:00
wukko
0e82023db8 web/readme: add info about link prefill 2025-05-29 20:53:33 +06:00
wukko
2558f357a9 web/changelogs/11.0: add missing info 2025-05-29 20:39:33 +06:00
wukko
5e3d6107f9 api/stream/manage: fix usage of getInternalTunnel()
fixed a typo
2025-05-29 20:20:56 +06:00
wukko
71bb2de81a web/package: bump version to 11.0 2025-05-29 20:10:37 +06:00
wukko
181669f949 api/package: bump version to 11.0 2025-05-29 20:10:28 +06:00
wukko
2df3673540 web/changelogs: add 11.0 changelog 2025-05-29 20:09:57 +06:00
jj
11520ccdf7 web/README: update for cobalt 11, add acknowledgments
Co-authored-by: wukko <me@wukko.me>
2025-05-29 12:42:02 +00:00
wukko
3c41585158 api/schema: add old variables from cobalt 10 for backwards compatibility 2025-05-29 17:55:52 +06:00
wukko
1a712db9e5 web/css: add <code> styling 2025-05-29 17:12:10 +06:00
wukko
f9a3fb1396 web/layout: add a rounded corner & top border when installed on desktop 2025-05-29 01:25:36 +06:00
wukko
d4a2fe507f web: add support for "remux" type of local processing
it's currently used for fixing a very specific set of twitter videos, but will be used for remuxing HLS videos in the future
2025-05-29 00:23:56 +06:00
wukko
bc8dcd5a97 web/ProcessingQueueItem: show running text even if there's no percentage 2025-05-28 23:30:14 +06:00
wukko
50a0f29ed9 docs/api: rewrite with up-to-date info and better formatting 2025-05-27 18:06:56 +06:00
wukko
c2d76010c5 api/core: remove durationLimit from server info
it's not used, no clue why it was here in the first place
2025-05-27 17:12:58 +06:00
wukko
0b36aa09a7 api/match: limit the duration limit number to 2 decimal places 2025-05-27 17:10:41 +06:00
jj
c392864c82 api/env: unquote variables if needed 2025-05-25 12:44:40 +00:00
jj
ba2d266de7 api: dynamic env reloading from path/url 2025-05-24 15:52:27 +00:00
jj
e76ccd1941 api: move env loading into separate file 2025-05-24 14:47:01 +00:00
jj
06ee65b55d api/api-keys: watch for file changes instead of polling 2025-05-24 14:32:50 +00:00
wukko
e43f712eb6 web/changelogs/10.5: remove corny remarks 2025-05-25 17:02:45 +06:00
wukko
7d84b74e9e web/device: allow default local processing on desktop & android firefox 2025-05-25 00:24:30 +06:00
wukko
bb8acc8b98 web/run-worker: add brackets around the case block with a const 2025-05-24 14:40:41 +06:00
wukko
2f6196f6e3 web/queue: remove final file from results without swapping for a dummy 2025-05-24 14:27:30 +06:00
jj
9c16efd3b1 web/lib/download: delay revoking object URL for 10s
just to be safe
2025-05-23 17:50:24 +00:00
jj
892c055d6a web/queue: replace pipelineResults array with object 2025-05-23 17:44:47 +00:00
jj
17bcfa3a03 web/queue: more uuid refactoring 2025-05-23 17:37:04 +00:00
jj
47683cecec web/types: create uuid alias for worker ids 2025-05-23 17:05:31 +00:00
wukko
78cf73b34e web/CaptchaTooltip: make animation not annoying 2025-05-23 22:56:32 +06:00
wukko
71ea3239a7 web/Omnibox: download right away after pressing paste
cuz turnstile is awaited in api lib now
2025-05-23 22:44:05 +06:00
wukko
c08352bda9 web/UpdateNotification: fix position on mobile 2025-05-23 22:39:41 +06:00
jj
b21e66e942 web/queue: clamp percentage between 0 and 100 2025-05-23 15:57:08 +00:00
jj
c647e191f3 web/workers/fetch: rename totalBytes to expectedSize 2025-05-23 15:55:29 +00:00
wukko
5cd911bbde web/remux: enable prerendering back
cuz this page doesn't do anything anymore
2025-05-23 20:52:25 +06:00
wukko
2c10ba7efa web/Omnibox: automatically start saving after link prefill 2025-05-23 20:51:44 +06:00
wukko
add0ab4adf web/lib/api: wait for turnstile solution, refactor
now cobalt waits for turnstile for 15 seconds before showing an assistive dialog, instead of showing the dialog right away. much better ux!
2025-05-23 20:37:47 +06:00
wukko
1c5e038372 web/Omnibox: show a tooltip if turnstile isn't solved 2025-05-23 20:19:52 +06:00
wukko
34b51745fa web/OmniboxIcon: refactor to svelte 5 style 2025-05-23 20:17:37 +06:00
wukko
e73942200b web: replace regular noto sans mono with a custom font with 3 characters
also fixed flicker that i introduced in the last commit

this font is not used anywhere outside of the download button, so it makes no sense to load the entire font
2025-05-23 16:54:28 +06:00
jj
22eb05bf98 web/dialog: fix meowbalt not being displayed on nojs popup 2025-05-23 10:45:26 +00:00
wukko
8ca793f69b web/DownloadButton: fix font family 2025-05-23 16:01:02 +06:00
wukko
be84f66dff web/about: revise text on all pages & improve readability
all information is way easier to read and understand. i also added info about new features and explained some concepts in a better language.
2025-05-23 15:57:33 +06:00
wukko
4d29bca13b web: fix long text font, make it IBM Plex Mono just like the rest of UI 2025-05-23 15:30:40 +06:00
jj
2eadc3fbd8 api/create-filename: relax sanitizeString and use fullwidth replacements 2025-05-23 07:44:05 +00:00
wukko
f36c749692 api/twitter: add support for saving media from ad cards 2025-05-23 13:06:51 +06:00
wukko
e7f2244579 web/SettingsCategory: workaround for opacity bug in ios safari 2025-05-23 00:53:41 +06:00
wukko
9dc58b19bf web/ProcessingQueueItem: add pipeline result sizes only for fetch 2025-05-23 00:19:10 +06:00
wukko
7732188870 web/ProcessingQueueItem: account for file sizes from completed workers 2025-05-22 23:48:12 +06:00
wukko
788098cc88 web/ProcessingQueueItem: prettier file actions reveal 2025-05-22 23:36:20 +06:00
wukko
ae8eee099f web/ProcessingQueueItem: remove strict progress step marker
cuz workers can run out of order & concurrently now
2025-05-22 23:27:24 +06:00
wukko
9452a8d8fe web/ProcessingQueueItem: timeout the download button 2025-05-22 23:17:13 +06:00
wukko
e99cf255c5 web/layout: remove the nav border on mobile
oops
2025-05-22 21:57:39 +06:00
wukko
f1c9ef2cce web/UpdateNotification: fix top margin 2025-05-22 21:15:35 +06:00
wukko
a1bf0a454f web/layout: remove content margin (frame)
it feels unnatural in safari and other browsers with colored headers

most browsers have their own frame nowadays (for some reason) so in those it looks even weirder than in regular ones (such as helium). just a waste of space.
2025-05-22 18:19:49 +06:00
wukko
7e9b7542ac web/Omnibox: workarounds for border rendering bugs in browsers
- fixes wonky input border in webkit
- fix bleeding rounded edges when focused in blink (caused by imperfect stacking of inset box-shadow and outset outline)

WOC (wukko-only-change) but it makes a huge difference imo
2025-05-22 18:16:32 +06:00
wukko
98cd4dfc0d web/queue: in-place queue task retrying
no more duplicate tasks
2025-05-19 22:49:54 +06:00
wukko
479a64890e web/i18n/settings: update youtube codec description 2025-05-19 22:06:47 +06:00
wukko
3c654bf864 web/ProcessingQueueItem: show buttons on tab focus
& prevent focus when queue isn't visible
2025-05-19 22:02:07 +06:00
wukko
16e69d8aee web/ProcessingQueueItem: fix clear button focusability
it's no longer focusable when popover is hidden, fr this time
2025-05-19 22:01:31 +06:00
wukko
b12a1e02a8 web/ProcessingQueue: fix clear button focus ring & fix tab nav 2025-05-19 21:09:31 +06:00
wukko
46c5e2e2b5 web/download: use shareFile or openFile depending on file size on ios 2025-05-19 20:35:42 +06:00
wukko
46942a36b3 web/SettingsInput: make input box fully clickable, fix radius of buttons
radius & inner padding on right were 1px off >:(
2025-05-19 20:05:35 +06:00
wukko
12d6f33197 web/app.css: fix text color in active buttons with focus ring 2025-05-19 19:44:51 +06:00
wukko
f94606cbd3 web/SectionHeading: fix outline offset on link copy button 2025-05-19 19:25:15 +06:00
wukko
1be6d2f7c1 web/SettingsInput: update the "hide sensitive input" icon 2025-05-19 18:52:28 +06:00
wukko
566194d8a6 web/lib/download: allow opening file object url on ios
sharing a big file crashes ios safari, but opening it works perfectly fine. will revert if this causes even more issues than before
2025-05-19 17:39:51 +06:00
wukko
5e1e083ff3 web/lib/download: don't wrap a file inside a file when sharing
wtf
2025-05-19 17:03:06 +06:00
wukko
b6693cd4b2 web/queue: fix total progress
the issue was caused by currentTasks state dependence in ProcessingQueue, now it's properly updated just like before :3
2025-05-19 16:32:48 +06:00
wukko
b96b57c216 web/components/queue: update to svelte 5 style 2025-05-19 16:03:50 +06:00
jj
398681857b web: parallel queue item processing 2025-05-17 18:18:19 +00:00
jj
426c073d5f web/queue: move error() out of createSavePipeline closure 2025-05-16 14:58:30 +00:00
jj
3d92a85ba2 web/queue: decompose ffmpeg construction in createSavePipeline 2025-05-16 14:50:57 +00:00
wukko
d6ad74d429 web/ProcessingQueue: processing list role & label 2025-05-14 22:43:16 +06:00
wukko
9b20d726a7 web/SectionHeading: add id to the title 2025-05-14 22:42:22 +06:00
wukko
294273e2a7 web/ProcessingStatus: refactor to svelte 5 & add aria label 2025-05-14 22:41:55 +06:00
wukko
773d771c40 web/DropReceiver: remove aria-hidden
why the hell was it aria hidden
2025-05-14 22:31:20 +06:00
wukko
d337de1f63 web/ProcessingQueueItem: make buttons accessible for screen readers 2025-05-14 22:25:46 +06:00
wukko
fdc4f4826d web/ProcessingQueueItem: floor the progress 2025-05-14 22:24:42 +06:00
wukko
08168f5477 web/SectionHeading: refactor to svelte 5 style 2025-05-14 22:23:33 +06:00
wukko
d4ca8ece00 web/remux: fix file receiver file import 2025-05-14 21:55:16 +06:00
wukko
9cf40549e3 web/workers/fetch: retry only when needed & reduce attempts to 3 2025-05-14 21:16:38 +06:00
wukko
a2d12ce82f docs/api-env-variables: add tunnel rate limit envs 2025-05-14 19:07:31 +06:00
wukko
0ae0bbfa1f api/core: use new tunnel rate limit env 2025-05-14 19:07:11 +06:00
wukko
a66e789317 api/config: add tunnel rate limit env 2025-05-14 19:06:59 +06:00
wukko
e7a3ab81d2 web/ProcessingQueue: prevent cobalt from being closed
if queue progress is not 0% or 100%
2025-05-14 17:30:47 +06:00
wukko
68554c5b53 web/remux: add imported files to queue automatically & filter by type 2025-05-14 16:08:13 +06:00
wukko
b1b5f3bba2 web/types/queue: remove obsolete todo comment 2025-05-14 15:37:54 +06:00
wukko
deb4adc4e8 web/i18n/error: remove unused strings 2025-05-14 15:37:45 +06:00
wukko
345df13647 web/workers/ffmpeg: error codes, better error handling, remove logs 2025-05-14 15:37:31 +06:00
wukko
8139e77b66 web/workers/fetch: proper error codes, remove debug logging 2025-05-14 15:36:02 +06:00
wukko
50746be9bf web/task-manager/scheduler: proper error code 2025-05-14 15:35:06 +06:00
wukko
4a6f159e06 web/task-manager/runner: proper error codes, remove debug logging 2025-05-14 15:34:40 +06:00
wukko
9d129bc865 web/task-manager/run-worker: error if ffmpeg args are missing
instead of just hanging
2025-05-14 15:33:30 +06:00
wukko
bcad963c10 web/i18n/error/queue: add processing queue error strings 2025-05-14 15:33:00 +06:00
wukko
eeda4beb25 web/task-manager/queue: show a dialog on error 2025-05-14 15:29:19 +06:00
wukko
683f161520 web/i18n/error: move api errors to a separate file 2025-05-14 15:27:26 +06:00
wukko
700067c4ec web/saving-handler: refactor error dialog handling
& remove debug logging for local processing
2025-05-14 15:26:39 +06:00
wukko
68e8b3369d web/ProcessingQueueItem: localize the error code
& fix status text line break
2025-05-14 14:58:22 +06:00
jj
bb177d8c81 web/migrate/v5: initialize settings subobjects if they are missing 2025-05-14 08:03:34 +00:00
wukko
841d602f3b web/Omnibox: use search params only in a browser
forever cursed by prerender
2025-05-07 19:56:39 +06:00
wukko
393d60ef7a web/Omnibox: fix prefilled link parsing & refactor to svelte 5 style 2025-05-07 19:45:32 +06:00
wukko
0e836fa4fc docs/api-env-variables: add "never" to FORCE_LOCAL_PROCESSING 2025-05-06 17:40:53 +06:00
wukko
06b865e965 api/config: limit acceptable FORCE_LOCAL_PROCESSING values
and throw an error if the value is wrong
2025-05-06 17:33:06 +06:00
wukko
1630514611 web/i18n/settings: update local processing toggle text 2025-05-06 16:49:46 +06:00
wukko
29b174fa0b docs/api-env-variables: rephrase yt audio & 0/1 value descriptions 2025-05-06 16:22:35 +06:00
wukko
c83ab63ade docs/api-env-variables: add local processing & youtube audio envs 2025-05-06 16:17:45 +06:00
wukko
4d582798bf api/match: force local processing when configured to do so in env 2025-05-06 16:16:52 +06:00
wukko
c5acb45557 api/core: mark request as session when bearer token is used
and pass it to match() for future consumption
2025-05-06 16:16:29 +06:00
wukko
d0539118ce api/config: add FORCE_LOCAL_PROCESSING 2025-05-06 16:12:51 +06:00
jj
42b7a6ae60 lib/opfs: move getting dir into remove() try catch 2025-05-04 19:18:27 +00:00
jj
d83d448190 web/opfs: make removeFromFileStorage() more robust 2025-05-04 19:05:32 +00:00
jj
b6a207a9b0 web/workers: append type to outputted files 2025-05-04 19:01:37 +00:00
jj
f655432376 lib/storage: always return files 2025-05-04 19:01:37 +00:00
wukko
9a9b9a6dfc web/PickerItem: make the image fade in longer 2025-05-04 16:14:37 +06:00
wukko
3bbd0f9442 web/vite.config: enable build sourcemap 2025-05-04 16:14:24 +06:00
wukko
70a970c453 web/FileReceiver: fix meowbalt fade in 2025-05-04 16:07:59 +06:00
wukko
37877a2453 web/ChangelogEntryWrapper: refactor to svelte 5 style 2025-05-04 16:01:22 +06:00
wukko
e18664e879 web/ChangelogEntry: refactor to svelte 5 style, fade in banner 2025-05-04 16:01:10 +06:00
wukko
d717cf1aaa web/PickerItem: better css for skeleton 2025-05-04 15:47:55 +06:00
wukko
d58155426f web/PickerItem: props refactor, fade in images on load
feels really good now
2025-05-04 15:36:13 +06:00
wukko
0cecdc32a6 web/Meowbalt: fade in meowbalt assets on load 2025-05-04 15:30:45 +06:00
wukko
6370420392 web/svelte.config: allow inline img event call
will be used for smooth fade in of images
2025-05-04 15:10:53 +06:00
wukko
c9dfd60068 web/ProcessingQueueStub: static stub text 2025-05-04 14:43:55 +06:00
jj
1ef8391639 web: simplify CobaltFileReference type 2025-05-03 13:23:05 +00:00
jj
95ab81eb10 web: use AbstractStorage everywhere 2025-05-03 12:21:21 +00:00
jj
ce4ded64a2 web/storage: add memory storage and init() function 2025-05-03 12:21:21 +00:00
jj
be4e7e2d7d lib/storage: drop read() method, widen res() to Blob
we don't use it anywhere, we only use res()
2025-05-03 12:21:21 +00:00
jj
dd507e1dcd lib/storage: add abstract storage class 2025-05-03 12:21:21 +00:00
jj
e0ced00806 lib/storage: move opfs to subdirectory 2025-05-03 12:21:21 +00:00
jj
1058014c96 web/storage: make opfs check more robust 2025-05-03 12:21:21 +00:00
jj
893c6edde7 web/storage: remove getStorageQuota()
not used anymore
2025-05-03 12:21:21 +00:00
jj
b3f151f3cb web/storage: move clearCacheStorage() logic to clear button 2025-05-03 12:21:21 +00:00
jj
54ec1645fe web/opfs: capitalize processing dir constant 2025-05-03 12:21:21 +00:00
wukko
a22e4c3cf9 web/settings: add an option to hide the remux tab on mobile 2025-05-01 00:25:03 +06:00
wukko
a1e20ccc3e web/SmallDialog: delay error haptics by 150ms 2025-04-30 23:56:25 +06:00
wukko
a20d375c51 web/package: update encode libav 2025-04-30 22:42:49 +06:00
wukko
931a815c29 web/queue: add gif pipeline & proper media type icons 2025-04-30 22:02:00 +06:00
wukko
a95f87ebfb api & web: make "basic" the default filename style 2025-04-30 21:33:36 +06:00
wukko
a86c552183 web/ProcessingQueueItem: don't show size if size is 0, refactor 2025-04-30 21:33:35 +06:00
wukko
c5d5ed161d web/i18n/queue: add state strings for encoding
it's "transcoding" cuz we never *encode* raw data
2025-04-30 21:33:35 +06:00
jj
8b9d63fdac web/svelte: drop svelte:component when stripping announcer 2025-04-30 15:24:17 +00:00
jj
6b11e49d4a web/workers: rename remux() function to ffmpeg() 2025-04-30 15:09:00 +00:00
jj
54408b159e web/taskmgr: flip gif todo condition 2025-04-30 14:53:52 +00:00
jj
72857e64a8 web/queue: add support for "remux" task type 2025-04-30 14:49:04 +00:00
jj
0716f97a3a web/workers: refactor remux worker into ffmpeg worker 2025-04-30 14:48:44 +00:00
jj
07443942fb web/libav/wrapper: add support for encode variant 2025-04-30 14:47:55 +00:00
jj
76462ee665 web/workers: refactor and clean up types 2025-04-30 10:28:22 +00:00
wukko
507ba66f78 web/app.html: preload code just like before
it adds 40kb of initial bandwidth load but makes the experience much better
2025-04-30 14:53:50 +06:00
wukko
9a3d35185b merge: soundcloud fix from main 2025-04-30 13:11:02 +06:00
wukko
4b9644ebdf api/package: bump version to 10.9.4 2025-04-29 23:10:18 +06:00
wukko
00b217796f api/soundcloud: fix short link url & refactor 2025-04-29 22:55:52 +06:00
wukko
33d029d3b5 web/app.html: preload code on hover
experiment, might change this back later
2025-04-28 23:22:27 +06:00
wukko
a12cb110fb web/types/changelog: fix ChangelogImport type 2025-04-28 22:55:28 +06:00
wukko
76b04aabf0 web: update dependencies to svelte 5
just updating the dependencies for now, will migrate components gradually over time
2025-04-28 22:52:22 +06:00
wukko
f9aaacb3ca web/settings/appearance: remove unused import 2025-04-28 22:38:36 +06:00
wukko
bd5c16ed15 web/DonateOptionsCard: fix css selector for scroll button 2025-04-28 22:37:24 +06:00
wukko
04d1a2f96f web/Omnibox: don't bind the download button to a variable 2025-04-28 22:02:52 +06:00
wukko
78f23da0a5 web/layout: adaptive status bar colors for desktop
mostly used by safari
2025-04-28 21:41:52 +06:00
wukko
2fce88af2f web/DonateOptionsCard: fix fantom button focus on tab nav, refactor 2025-04-27 21:32:40 +06:00
wukko
44dc9ca9dd web/app.css: add focus ring for select & fix active button focus color 2025-04-27 21:03:24 +06:00
wukko
4de00b6240 web/app.css: prettier focus ring on links 2025-04-27 20:59:50 +06:00
wukko
55ce09d6f4 web/Switcher: fix box-shadow 2025-04-27 20:59:14 +06:00
wukko
9657db3515 web: remove focus ring css workarounds 2025-04-27 20:38:01 +06:00
wukko
ba4742f3fd web: remove data-focus-ring-hidden from all files 2025-04-27 20:37:38 +06:00
wukko
0454b138b1 web/FileReceiver: restore the pretty animated focus ring 2025-04-27 20:36:14 +06:00
wukko
0e1750e215 web: use outline for focus ring instead of box-shadow
- prevents conflicts with existing box-shadow(s) on basically all components
- removes the need for data-focus-ring-hidden or any other weird workarounds
2025-04-27 20:35:50 +06:00
wukko
e3a60d8775 web/ProcessingStatus: visually distinct focus ring 2025-04-27 20:30:39 +06:00
wukko
d25e9b628e web/ProcessingStatus: remove extra box-shadow css 2025-04-27 20:16:44 +06:00
wukko
c4fc320a6a web/SupportedServices: clean up a bit 2025-04-27 19:35:30 +06:00
wukko
9d6e638614 web/ProcessingStatus: replace box-shadow with filter 2025-04-24 17:28:06 +06:00
wukko
b3e523b1ce web/PopoverContainer: replace box-shadow with filter & add will-change 2025-04-24 17:20:33 +06:00
wukko
926008818e web/DialogHolder: improve first animation performance, prevent lag
sometimes the initial dialog animation lags, and as i've discovered it's mostly caused by animating box-shadow (even though it's not directly animated). replacing it with filter seems to have improved the performance a LOT lol.

also:
- made the in animation jumpier
- delayed the animation of modal a bit to let the background start appearing first
- extended opacity fade in by 5%
2025-04-24 17:18:22 +06:00
wukko
f21f16a700 web/PopoverContainer: reduce the shadow 2025-04-24 16:31:10 +06:00
wukko
4202c954d1 merge: api 10.9.3 from main 2025-04-24 16:27:17 +06:00
wukko
de6b611c41 api/package: bump version to 10.9.3 2025-04-24 16:15:09 +06:00
wukko
d0deec546b api/service-config: add new snapchat link pattern 2025-04-24 15:44:52 +06:00
wukko
7ff6d0036b api/snapchat: prevent errors if params are undefined 2025-04-24 15:44:34 +06:00
wukko
1335313e39 api/service-patterns: increase xiaohongshu shareId max length 2025-04-24 15:30:52 +06:00
wukko
064de55b3b web/ProcessingQueue: remove estimated storage usage
it's broken in pretty much all browsers and shows inaccurate info
2025-04-23 23:09:32 +06:00
jj
84e8160999 web/fetch: use estimated length only for progress reports 2025-04-23 16:48:58 +00:00
jj
d1bb1764df web/fetch: use estimated-content-length if content-length is unavailable 2025-04-23 16:44:53 +00:00
wukko
37a71837a7 web/lib/saving-handler: regular get for localProcessing 2025-04-23 22:36:20 +06:00
wukko
c1b592430a merge: api updates from main 2025-04-23 21:10:52 +06:00
wukko
6d315e3e74 web/layout: fix border radius of content on desktop
now it's perfectly aligned with macos' window border radius. probably on windows 11 too cuz they copied macos big sur
2025-04-23 21:03:48 +06:00
jj
6f6f885723 api/youtube: update Session arguments to match new constructor 2025-04-23 14:02:27 +00:00
wukko
678f3a6c57 package: update youtubei.js to 13.4.0 2025-04-23 18:58:00 +06:00
wukko
ea8560e8a9 web/settings/defaults: toggle localProcessing depending on device
webkit is really weird with wasm ram management, so we disable local processing by default there. the user can still enable it manually in settings, but then we're not at fault by allowing potentially broken behavior by default
2025-04-21 23:06:25 +06:00
wukko
029934e580 web/lib/device: add webkit browser detection 2025-04-21 23:03:22 +06:00
wukko
4182845e9a web/FileReceiver: make accept list text unselectable 2025-04-21 22:46:34 +06:00
wukko
016aa1b708 web/settings: rename "downloading" to "metadata"
am i future proofing for more stuff there? maybe
2025-04-20 21:03:02 +06:00
wukko
b9c1f2de72 web/settings/audio: add a toggle to prefer better audio quality from yt
& also remove the beta label from youtube dub
2025-04-20 20:49:28 +06:00
wukko
e3f999ace7 web/lib: add support for youtubeBetterAudio 2025-04-20 20:45:52 +06:00
wukko
335cd51eb5 api: add an option to allow better audio from youtube
& an env variable to disable it if needed
2025-04-20 20:45:07 +06:00
wukko
0294bbd447 web/i18n/settings: update youtube hls description 2025-04-20 19:24:58 +06:00
wukko
0b1637e986 web/i18n/queue: mux -> remux
all muxing that cobalt does is remuxing, this is simply more accurate
2025-04-20 19:05:30 +06:00
wukko
128db610e7 web/task-manager: move workers next to runners 2025-04-20 19:03:56 +06:00
wukko
60817d7a21 workflows/test: add WEB_DEFAULT_API env to web run 2025-04-20 16:38:06 +06:00
wukko
e5d9521819 web/api-url: don't return officialApiURL 2025-04-20 16:31:04 +06:00
wukko
b56c6b70a2 web/Omnibox: show community label if default url isn't official 2025-04-20 16:18:45 +06:00
wukko
b0fba0dadb web/api-url: rename defaultApiURL to officialApiURL 2025-04-20 16:18:14 +06:00
wukko
0f26d44d54 web/vite.config: enforce WEB_DEFAULT_URL variable 2025-04-20 16:14:39 +06:00
wukko
1a1206809e web: move global css to app.css 2025-04-20 15:03:36 +06:00
wukko
d1798bc59d merge: updates from main 2025-04-20 14:21:39 +06:00
wukko
f5598d7897 web/SavingDialog: fix unexpected inner container box-shadow 2025-04-05 23:03:26 +06:00
wukko
06bc51db54 merge: 10.9 from main 2025-04-02 21:48:58 +06:00
wukko
fc050d78e2 api/package: bump version to 10.9.1 2025-04-02 21:41:43 +06:00
wukko
07d4393d27 web/Omnibox: don't rerender omnibox buttons
this prevents unnecessary listener creation on valid/invalid link spam
2025-04-02 21:22:01 +06:00
wukko
cc5dff0a30 web/DownloadButton: fix RTL layout 2025-04-02 21:14:45 +06:00
wukko
fc42fd7a86 web/Omnibox: make the entire input bar focusable
now it's possible to click through the omnibox icon
2025-04-02 21:14:24 +06:00
wukko
9c40a1f88e web/PageNav: reduce padding, add a gap between nav and content 2025-04-02 20:16:42 +06:00
jj
07f81c5d1d api/reddit: clean up duplicated headers 2025-04-02 12:35:45 +00:00
jj
f5df78ffec api/utils: retry getting redirecting url with fetch() if request() fails 2025-04-02 12:29:18 +00:00
Felix Vuong 🍂
a6240d0192 api/url: replace user-agent argument with headers in redirect helpers 2025-04-02 11:44:13 +00:00
Felix Vuong 🍂
b1bde25dee api/reddit: add support for short links 2025-04-02 11:42:59 +00:00
wukko
1477dcd4e7 api/tests/instagram: allow the private post test to fail
sometimes the visibility status isn't returned
2025-04-02 17:35:01 +06:00
wukko
0fb4cd7888 web: rename queen-bee to task-manager
less corny and less cryptic
2025-04-02 14:57:45 +06:00
wukko
f4f7032062 web/scheduler: break the queue loop when necessary 2025-04-02 14:06:48 +06:00
wukko
ba36b6b2f7 github: add a staging workflow & update the name of others 2025-04-01 15:57:17 +06:00
wukko
6fbc585155 web/package: bump version to 10.9 2025-04-01 15:35:36 +06:00
wukko
d352eed85f api/package: bump version to 10.9 2025-04-01 15:35:25 +06:00
wukko
6da12a2e03 readme: add a link to api env variables doc 2025-04-01 00:41:58 +06:00
wukko
c694c297c0 docs/run-an-instance: add a note about abuse prevention 2025-04-01 00:39:27 +06:00
wukko
545971186a docs: create a dedicated doc for api instance env variables
& also move "api key file format" section to the actually relevant doc, aka `protect-an-instance`
2025-04-01 00:38:04 +06:00
wukko
f70f88bc4c api/core: customizable session rate limit params 2025-03-31 22:32:21 +06:00
jj
75e1fb689a web/queue: refactor removeItem() and clearPipelineCache() 2025-03-31 13:05:01 +00:00
jj
165fa65964 web/scheduler: rename function to schedule(), cleanup control flow 2025-03-31 13:05:00 +00:00
jj
a183265838 web/workers: split up args by type 2025-03-31 13:05:00 +00:00
jj
53ca7700a5 web/queue: make completedWorkers into set, require pipelineResults 2025-03-31 13:05:00 +00:00
jj
59665af44a web/api: re-request session if server claims it's invalid 2025-03-30 17:41:28 +00:00
jj
1f768df4ec api: bind session tokens to ip hash 2025-03-30 17:08:34 +00:00
jj
d78ae8124f web/queen-bee: a bunch of small cleanups 2025-03-29 14:21:04 +00:00
wukko
bf5937e336 api/package: bump version to 10.8.4 2025-03-29 19:48:11 +06:00
wukko
235f6c0a3e web/queue: add support for video muting
also added a temporary stub for audio and gif processing
2025-03-29 17:55:40 +06:00
wukko
180bda5d49 web/types/api: add proper local processing types 2025-03-29 17:44:45 +06:00
wukko
1ad7c778e5 web: use metadata when processing media locally 2025-03-29 17:25:59 +06:00
jj
aa0d1aad1d docs/compose: remove port for yt generator, add watchtower label 2025-03-29 10:28:11 +00:00
jj
39274d88f6 api/youtube-session: bypass proxy for requests
usually the session server is hosted locally, which
means the proxy tries to access the wrong url
2025-03-29 10:28:11 +00:00
wukko
3acfe7462a web/SettingsCategory: reduce gap 2025-03-29 14:36:03 +06:00
wukko
4b0d44912b web/Omnibox: reduce the gap 2025-03-29 14:32:57 +06:00
wukko
b9e64bd9e9 web/OmniboxIcon: refactor css 2025-03-29 14:21:53 +06:00
wukko
0b77431bd6 web/OmniboxIcon: don't use extra flexboxes 2025-03-29 14:01:38 +06:00
wukko
ccf6546065 web/ProcessingQueue: make storage info text thicker 2025-03-29 13:44:24 +06:00
wukko
af8cbb1093 web: add "will-change: transform" to all spinners cuz safari is weird 2025-03-29 13:19:27 +06:00
wukko
4af3595344 web/i18n/error: rephrase youtube login & token errors 2025-03-29 13:07:52 +06:00
wukko
071008726e web/UpdateNotification: fix initial transition (animation) 2025-03-27 21:20:00 +06:00
wukko
8ffe9e29d6 web/ProcessingQueue: reduce the gap in the header 2025-03-27 20:34:15 +06:00
wukko
0b29121c53 merge: changes from main 2025-03-27 20:02:46 +06:00
wukko
2d38d63003 api/package: update youtubei.js to 13.3.0 2025-03-25 19:11:19 +06:00
wukko
5036c492b8 api/service-config/tiktok: remove trailing forward slash from a pattern 2025-03-25 18:32:05 +06:00
wukko
ab13f78326 api/tiktok: normalize short link URL & catch empty patternMatch 2025-03-25 18:31:12 +06:00
Felix Vuong 🍂
2f38260e23 api/service-config: add tiktok lite url pattern 2025-03-25 18:11:49 +07:00
jj
d13b97c862 api/cookies.example.json: add youtube example 2025-03-23 17:59:17 +01:00
lostdusty
0a7cf7580c api/core: remove non-printable unicode character in boot message (#1182) 2025-03-21 22:43:53 +06:00
wukko
36516598f9 api/package: bump version to 10.8.2 2025-03-21 22:34:03 +06:00
wukko
1be9a86745 api/tests/xiaohongshu: update the video link & allow to fail
all links expire apparently
2025-03-21 22:16:49 +06:00
wukko
c7c20c2157 api/tests/xiaohongshu: update the live photo picker link 2025-03-21 21:52:21 +06:00
wukko
b93099620f api/match/youtube: use 1080 dummy quality for audio-only downloads 2025-03-21 21:30:47 +06:00
wukko
cf17f53405 api/youtube: use the iOS client for <=1080p vp9 videos 2025-03-21 21:29:25 +06:00
wukko
ee94513580 api/package: bump version to 10.8.1 2025-03-20 18:11:04 +06:00
wukko
24ce19d09f api/youtube: use both ios & web_embedded client depending on request
this ensures better reliability & reduces rate limiting of either clients
2025-03-20 17:57:02 +06:00
wukko
e779506d9e api/package: update youtube.js
it contains a fix that's necessary for youtube to work rn
2025-03-20 17:49:08 +06:00
wukko
f8ee005b06 api/package: bump version to 10.8 2025-03-20 00:18:31 +06:00
wukko
da040f1a09 docs/examples/docker: add yt-session-generator example 2025-03-20 00:11:24 +06:00
wukko
f18d28dcfc web/i18n/error: add api.youtube.no_session_tokens 2025-03-20 00:09:46 +06:00
wukko
b7fb8d26ad docs/run-an-instance: add info about YOUTUBE_SESSION_SERVER 2025-03-19 20:49:52 +06:00
wukko
073b169a93 api: remove code & docs related to youtube oauth
it hasn't been functional for a while, unfortunately
2025-03-19 20:43:31 +06:00
wukko
d1b5983e49 api/youtube: disable HLS if a session server is used 2025-03-19 20:34:56 +06:00
wukko
4e6d1c4051 api/tests/youtube: allow HLS tests to fail 2025-03-19 20:32:44 +06:00
wukko
b6cd0ad727 api: automatically pull youtube session tokens from a session server
if provided, cobalt will pull poToken & visitor_data from an instance of invidious' youtube-trusted-session-generator or its counterpart
2025-03-19 19:54:20 +06:00
wukko
6a13ca347d api/request/local-processing: don't return an empty audio object
& also throw errors whenever a response is invalid
2025-03-19 13:38:55 +06:00
wukko
9eb342e6d2 web/queue: use the updated local processing api response
& finally remove mime from the web build
2025-03-19 12:25:51 +06:00
wukko
e497ea51f1 api/request: reformat the local processing response, add output mimetype 2025-03-19 12:24:26 +06:00
wukko
a8bffc4b27 web/layout: load the plausible script only once
oops
2025-03-17 17:37:00 +06:00
wukko
3295032882 web/layout: don't load the plausible script when analytics are disabled
addresses #1136
2025-03-17 17:19:50 +06:00
wukko
93ff9b62d6 web/DialogContainer: prevent an error after a race condition
an error is no longer thrown if several dialogs were closed while timeout was running

this should really be replaced by proper dialog management system, with each dialog having a unique id and removal happening via that id, not just array.pop()
2025-03-17 16:47:03 +06:00
wukko
5850b1ac87 web/layout: preload meowbalt art after the page was loaded 2025-03-17 15:29:51 +06:00
wukko
97fee5e6d4 merge: updates from main 2025-03-15 21:25:23 +06:00
wukko
a940eb13fd api/package: bump version to 10.7.10
it's kind of ridiculous at this point
2025-03-14 09:17:01 +06:00
wukko
f103bcfaa3 docs/run-an-instance: add info about CUSTOM_INNERTUBE_CLIENT 2025-03-14 09:05:58 +06:00
wukko
d2d098dbfb api/youtube: use custom innertube client env & decipher for more clients 2025-03-14 08:54:42 +06:00
wukko
e10fad3d4e api/config: add CUSTOM_INNERTUBE_CLIENT env 2025-03-14 08:53:26 +06:00
wukko
903998913f web/PageNavTab: add a border to inactive tab icon 2025-03-13 18:54:33 +06:00
wukko
2197d9411e merge: updates from main 2025-03-13 14:56:49 +06:00
wukko
aba23f8655 api/package: bump version to 10.7.9 2025-03-13 14:56:31 +06:00
wukko
5900d6aa4a web/i18n/error: add youtube drm error 2025-03-13 13:30:05 +06:00
wukko
2ebe2899be api/youtube: return an appropriate error if a video is locked behind DRM 2025-03-13 13:23:03 +06:00
hyperdefined
d00d94f3dc api/pinterest: fix video parsing (#1153)
fixes #1148
2025-03-12 12:35:27 +01:00
wukko
440d039e2c api/package: bump version to 10.7.8 2025-03-11 14:10:01 +06:00
wukko
39b6bb2593 api/twitter: change const to let for media 2025-03-11 14:01:34 +06:00
wukko
9579c3dd08 api/twitter: fix return in extractGraphqlMedia 2025-03-11 13:59:59 +06:00
wukko
69421a11ad api/twitter: refactor, move graphql media extraction to a function 2025-03-11 13:58:24 +06:00
wukko
e6e2fea870 web/layout: preload meowbalt assets
no more flickering i hope

is this rational? maybe not so much, but it makes cobalt feel like a native app
2025-03-11 13:26:44 +06:00
wukko
30460586c4 api/tests/twitter: add a gif test 2025-03-11 12:34:07 +06:00
wukko
75b498ed77 api/twitter: add fallback to syndication api
it's back yet again, now for good, i suppose
2025-03-11 12:34:04 +06:00
wukko
69dd37c5c3 api/twitter: handle 403 with no cookie in requestTweet() 2025-03-11 10:25:49 +06:00
wukko
9639c599f0 api/twitter: handle empty body properly 2025-03-11 10:00:24 +06:00
wukko
429591c445 web/FilenamePreview: reduce line height 2025-03-10 13:47:53 +06:00
wukko
95a5a8ae9b web/haptics: fix disableHaptics setting path
bub fix
2025-03-07 21:50:02 +06:00
wukko
a5172b8fb4 web/settings/accessibility: add toggle for disabling auto opening queue 2025-03-07 21:47:02 +06:00
wukko
1b0be14175 web/settings: move accessibility settings to the accessibility page
also rearranged the nav bar a bit to make it look cleaner

... and also accommodated for the new location of accessibility settings (oops)
2025-03-07 21:43:03 +06:00
wukko
4a5f0aa52c web/queue-visibility: don't auto open the queue if disabled in settings 2025-03-07 21:36:54 +06:00
wukko
1f0abf5169 web/lib/settings: add accessibility section, add dontAutoOpenQueue
moved `reduceMotion`, `reduceTransparency`, and `disableHaptics` to accessibility, migrated first two from old version of settings
2025-03-07 21:35:39 +06:00
wukko
1137ccfd3b web/ProcessingQueue: open the queue popover when new item is added 2025-03-07 21:03:50 +06:00
wukko
714e71751e web/PopoverContainer: refactor & simplify code
why the fuck was it that way in the first place
2025-03-07 20:47:43 +06:00
wukko
3935396709 web/i18n/queue: update running remux text 2025-03-07 15:48:14 +06:00
wukko
7dc2683180 web/i18n/queue: update the queue title 2025-03-07 15:20:50 +06:00
wukko
dab88f7ed8 web/ProcessingStatus: update the icon 2025-03-07 15:20:34 +06:00
wukko
187bf9d745 merge: api 10.7.7 from main 2025-03-07 00:07:52 +06:00
wukko
c346d2b027 api/package: bump version to 10.7.7 2025-03-06 23:43:13 +06:00
jj
97f71df962 api/tests: replace broken facebook video link 2025-03-06 17:23:36 +00:00
jj
068ae2f2e7 api/internal: also transplant youtube HEAD requests 2025-03-06 17:16:46 +00:00
wukko
a84b21a501 web/runners/remux: retry to run the worker 10 times awkwardly
this is absolutely foul and really needs fixing but i guess it works for now 😭
2025-03-06 22:50:42 +06:00
wukko
4a1780ab7f web/ProcessingQueueItem: refactor, retry action, rtl optimization
also:
- added a spinner to "running" state
- moved steps counter to the starting state, aka when the worker is loading in
2025-03-06 18:30:48 +06:00
wukko
6a4de1be28 web/PopoverContainer: flip transform origin in rtl 2025-03-06 17:43:30 +06:00
wukko
d8b274f554 web/layout: global spinner animation 2025-03-06 17:22:08 +06:00
wukko
0bee4b1ade web/queue/createSavePipeline: store original request & allow to retry 2025-03-06 17:04:47 +06:00
wukko
a3a273a4b1 web/queue: add canRetry and originalRequest to queue items 2025-03-06 17:03:55 +06:00
wukko
158ba6f28f web/saving-handler: destructure params, reuse request if passed 2025-03-06 17:02:06 +06:00
wukko
d98cb4c2d7 web/util/formatFileSize: don't parseFloat, allow .00 to stick around
prevents rapid jiggle in the queue
2025-03-06 16:57:49 +06:00
wukko
f9c0decd4c web/api: move api request creation to saving-handler & limit the type
prerequisites for reusing the requests 👀
2025-03-06 15:58:31 +06:00
wukko
9225b31986 web/workers/fetch: retry 5 more times before throwing an error
hopium

should probably add a timeout too
2025-03-06 14:30:52 +06:00
wukko
066a47c82d web/DownloadButton: fix the button width to prevent moving around 2025-03-06 14:25:31 +06:00
wukko
1f38bf822c web/app.html: remove error art prefetch cuz it makes no difference 2025-03-06 13:40:32 +06:00
jj
e8967c33d3 web/static: recompress all pngs 2025-03-05 16:53:59 +00:00
wukko
4f92ccf813 web/app.html: preload meowbalt error art
previously it just snapped into the error popup which was very ugly
2025-03-05 22:33:05 +06:00
wukko
7e71701e10 web/SmallDialog: add error haptics to error popups 2025-03-05 22:19:08 +06:00
wukko
a2e08b9ccb web/DownloadButton: refactor & add haptic feedback 2025-03-05 22:05:11 +06:00
wukko
bf0b9f55e5 web/Omnibox: add haptic feedback to the paste button 2025-03-05 22:04:50 +06:00
wukko
698905db2e web/settings/appearance: add a toggle for disabling haptics
also updated all descriptions for accessibility toggles
2025-03-05 21:46:27 +06:00
wukko
712318612d web/haptics: don't use haptics if disabled in settings 2025-03-05 21:40:26 +06:00
wukko
8af4c69be3 web/settings: add disableHaptics 2025-03-05 21:38:47 +06:00
wukko
e61ac61e20 web/settings/local: hide the webcodecs toggle if the feature not enabled 2025-03-05 20:36:09 +06:00
wukko
a3c9ccf5df web/env: temporary ENABLE_WEBCODECS variable 2025-03-05 20:35:10 +06:00
wukko
6e21fc56eb web/SettingsDropdown: add haptics 2025-03-05 20:18:52 +06:00
wukko
ef7fc8781b web/SettingsToggle: remove bg transition
cuz it was making the light/dark theme transition very awkward
2025-03-05 20:09:52 +06:00
wukko
0d3044c5e6 web: add haptics for all copy actions
& prevent spamming the copy action along with haptic feedback :3

should probably unify all of this cuz this is really messy
2025-03-05 18:07:46 +06:00
wukko
fd5f7c36b2 web/Toggle: jiggle physics & don't stretch on long press 2025-03-05 17:30:15 +06:00
wukko
6b09bd4688 web: add haptics to toggles & switchers 2025-03-05 17:21:45 +06:00
wukko
66401c6c5f web/UpdateNotification: replace animation with a springy transition
so cute :3
2025-02-27 21:05:29 +06:00
wukko
64680e162a web/Switcher: add box-shadow to active item 2025-02-27 20:41:11 +06:00
wukko
96142a3a0c web/SettingsToggle: make border shine when pressed 2025-02-27 20:40:13 +06:00
wukko
3651b98b2d web/DonateShareCard: reduce action button padding
might help with translations
2025-02-27 20:12:40 +06:00
wukko
dc0803d292 web/DonateCardContainer: don't show bg on scroll buttons 2025-02-27 19:17:58 +06:00
wukko
8934b25c47 web/DonateCardContainer: default cursor when a button is selected
also disabled hover & active for a selected button
2025-02-27 18:29:07 +06:00
wukko
238295888c web/DonateOptionsCard: faster scrolling, hover state for custom value 2025-02-27 18:15:51 +06:00
wukko
f5b9f59e43 web/DonateCardContainer: add an active button state 2025-02-27 18:12:15 +06:00
wukko
0b631b31b3 web/DonateAltItem: add missing button class 2025-02-27 18:05:42 +06:00
wukko
b4dd9efd92 web/PageNavTab: show border only when active 2025-02-27 17:42:18 +06:00
wukko
36de546fe2 web/SidebarTab: show border only when active
& also brighten when active on mobile
2025-02-27 17:41:39 +06:00
wukko
78db8d5eef web/SupportedServices: add hover & press states 2025-02-27 17:33:42 +06:00
wukko
2573089378 web/OmniboxIcon: spin the spinner only when it's visible
before it'd always spin in background while being invisible... which is probably not really good
2025-02-25 17:43:22 +06:00
wukko
c45c1d13c0 web/SettingsInput: validate input properly, reduce padding 2025-02-25 13:40:04 +06:00
wukko
631f8bddd8 web/FilenamePreview: reduce border, improve padding 2025-02-25 13:38:16 +06:00
wukko
ad9fd4f601 web/DownloadButton: fix 1.5px misalignment & add press state
also fixed opacity when disabled
2025-02-25 13:37:27 +06:00
wukko
20d24eca43 web/ClearButton: add missing button class 2025-02-25 13:36:07 +06:00
wukko
ceee059ecf web/Switcher: reduce padding 2025-02-25 13:35:57 +06:00
wukko
78a4c9adbf web/layout: better light mode colors for button states 2025-02-25 13:35:21 +06:00
wukko
0f21c9b236 web/layout: reduce button border by .5px
yes it matters a lot to me
2025-02-25 13:34:51 +06:00
wukko
104c9004c5 web/UpdateNotification: fix mobile position 2025-02-25 00:44:43 +06:00
wukko
0ae5cad2f5 web: fix PageNavTab & SidebarTab bg flicker on selection
it used to be: normal-> hover -> pressed -> hover -> active

but now it's: normal -> hover -> pressed -> active
2025-02-25 00:37:18 +06:00
wukko
24a75eaf80 web/components: add missing "button" class to main components 2025-02-25 00:17:52 +06:00
wukko
384ea412ea web/layout: bright sidebar in light mode, content frame 2025-02-25 00:13:37 +06:00
wukko
346b9084b0 web/PageNavTab: add press state & border on hover 2025-02-24 23:52:09 +06:00
wukko
bbc7629190 web/layout: move ProcessingQueue outside of content
because it's always above content
2025-02-24 23:49:01 +06:00
wukko
137fdd8c03 web/AboutSupport: add a missing button class 2025-02-24 22:38:59 +06:00
wukko
010dfff672 web/SettingsInput: add missing button classes 2025-02-24 22:37:09 +06:00
wukko
20c45823ee web/layout: fix dark mode button colors, proper press state for buttons 2025-02-24 22:34:00 +06:00
wukko
60f4009947 web/CobaltLogo: color the logo according to sidebar colors 2025-02-24 22:29:48 +06:00
wukko
efa09d7280 web/SettingsDropdown: remove duplicated hover declaration 2025-02-24 22:29:27 +06:00
wukko
33dd4b9fd8 web/SettingsToggle: add button class (because it's a button) 2025-02-24 22:29:05 +06:00
wukko
3e2c7a3c91 web/i18n/settings: fix video filename preview
now it displays the actual filename format you get
2025-02-24 22:28:33 +06:00
wukko
ded23ec29a web/layout: use the chrome workaround only in chrome lol
oops
2025-02-24 21:33:24 +06:00
wukko
424a16729e web/settings/local: update name of the media processing section 2025-02-24 18:46:11 +06:00
wukko
910e889f60 web/layout: don't use sign() in chrome cuz it's not supported there 2025-02-24 18:38:33 +06:00
wukko
5fa5a0e7cb web/device: add browser type (just chrome for now) 2025-02-24 18:36:32 +06:00
wukko
910cbcf236 web/AboutSupport: allow the card to fill the available space 2025-02-24 17:24:06 +06:00
wukko
2e317c3abe web/settings: update PageNav icon colors & icon for credits 2025-02-24 17:23:38 +06:00
wukko
969058d70b web/settings: update PageNav colors 2025-02-24 17:22:56 +06:00
wukko
52528ddee8 web/PageNavTab: add more colors 2025-02-24 17:12:58 +06:00
wukko
b2df289894 web/PageNavTab: cleaner icon style 2025-02-24 16:30:33 +06:00
wukko
8e4d0cd03d web/settings: add a local processing page 2025-02-24 15:51:11 +06:00
wukko
89fccae33d web/settings/migrate: migrate alwaysProxy 2025-02-24 15:49:07 +06:00
wukko
b463ec7a7d web/settings: move alwaysProxy & localProcessing, add useWebCodecs 2025-02-24 15:48:52 +06:00
wukko
540aee6194 merge: updates from main 2025-02-24 15:11:30 +06:00
wukko
187b1f8f05 api/package: update youtube.js to 13.1.0 2025-02-23 13:39:07 +06:00
wukko
82f3062759 api & web: bump package version to 10.7.5 2025-02-18 18:30:15 +06:00
wukko
7b63db13c4 web/i18n/error: add api.invalid_body & update api.unknown_response (#1118) 2025-02-18 12:44:53 +01:00
jj
dba405a6b4 api/facebook: add dispatcher support (#1115) 2025-02-18 17:44:25 +06:00
jj
a52aee2bb3 ci: use TEST_IGNORE_SERVICES variable for ignoring services 2025-02-18 09:12:55 +00:00
wukko
dcc99f0e62 merge: 10.7.4 from main branch 2025-02-13 17:20:11 +06:00
wukko
b540e48ffb api/package: bump version to 10.7.4 2025-02-13 17:19:32 +06:00
wukko
b5ba86dd75 api/youtube: return a proper error if the video is "inappropriate" 2025-02-13 17:09:03 +06:00
wukko
3d98b4f9e4 web/i18n/error: update age restriction errors 2025-02-13 17:07:20 +06:00
wukko
dcc5b5d2fd web/PickerDialog: adjust mobile scaling a bit 2025-02-13 01:05:08 +06:00
wukko
bc70cf4b6b web/DialogHolder: improve bottom margin in mobile pwa 2025-02-13 00:53:17 +06:00
wukko
8d7f0d984f web/layout: add & use the css variable for the focus ring 2025-02-13 00:32:02 +06:00
wukko
935947cafc web/PickerItem: add a proper focus ring & fix different border radius 2025-02-13 00:29:09 +06:00
wukko
553b3f9091 web/PickerDialog: align the grid perfectly, better scaling 2025-02-13 00:26:45 +06:00
wukko
c0b671e45f web/queen-bee: move runners to their own files 2025-02-12 13:34:52 +06:00
wukko
564fc65297 web/workers/remux: init libav only once, terminate after usage 2025-02-12 13:19:13 +06:00
wukko
ff62a4c2e6 web/types/libav: replace "extension" with "format" in FileInfo 2025-02-12 13:17:56 +06:00
jj
33ce314775 docs/run-an-instance: remove mention of "web" in composefile
this has not been relevant for a while now
2025-02-11 20:14:31 +01:00
wukko
c31c484894 merge: 10.7.3 from main 2025-02-11 16:18:30 +06:00
wukko
1830765101 web/package: sync version with api 2025-02-11 16:17:51 +06:00
wukko
80f9769d88 api/package: bump version to 10.7.3 2025-02-11 16:15:30 +06:00
wukko
4dc7d28696 api/instagram: fall back to photo in extractOldPost if video has no url 2025-02-11 15:42:16 +06:00
wukko
14556b3190 web/PickerDialog: ignore wrong items in an array 2025-02-11 15:39:53 +06:00
wukko
f76d40bec4 web/PickerItem: make sure the item url is valid 2025-02-11 15:29:23 +06:00
wukko
366279a3bc web/PickerDialog: don't render an item if it has no url 2025-02-11 15:25:01 +06:00
lostdusty
fa267ae54b api/core: return 429 http status for rate-limit (#1066)
Co-authored-by: jj <log@riseup.net>
2025-02-11 14:42:31 +06:00
jj
d8eda230e8 api/test: fix more twitter tests 2025-02-10 22:10:35 +00:00
jj
92061f2e82 api/run-test: print error code for unexpected errors 2025-02-10 22:06:30 +00:00
jj
fcb5023c23 api/test: always randomize ciphers and override envs 2025-02-10 22:04:57 +00:00
jj
d79950b15f api/test: clear a bunch of canFails 2025-02-10 22:03:10 +00:00
jj
0426621cf5 api/test: fix twitter bookmarked photo link 2025-02-10 21:59:11 +00:00
jj
71d17cc31d ci: use external proxy for tests 2025-02-10 21:55:20 +00:00
jj
a06bad161a api/test: add env for configuring ignored tests 2025-02-10 21:55:11 +00:00
jj
8f57881a68 api/test: use proxy from external proxy env if available 2025-02-10 21:48:30 +00:00
Satya Ananda
d6b0fbc8ec api/url: extract loom video id from longer links (#832)
Co-authored-by: jj <log@riseup.net>
2025-02-11 14:52:20 +06:00
Hk-Gosuto
20b1d9ab30 web/youtube-lang: add zh, zh-Hans, and zh-Hant language codes (#1076) 2025-02-11 14:44:06 +06:00
zjy4fun
ca0bc9f395 api/ok: fix author not being handled properly (#1009) 2025-02-11 14:42:07 +06:00
wukko
ad23b70e9d merge: api 10.7.2 from main branch 2025-02-10 15:27:53 +06:00
wukko
07947882c4 api/package: bump version to 10.7.2 2025-02-10 11:53:49 +06:00
wukko
de69989bbe api/service-config/instagram: add support for more share links 2025-02-10 11:53:37 +06:00
wukko
8ab5e32390 api/package: bump version to 10.7.1 2025-02-10 00:57:19 +06:00
wukko
09706160a9 api/snapchat: allow profile params to be missing
fixes broken story extraction
2025-02-10 00:33:23 +06:00
wukko
a0f227d68b api/reddit: add support for mobile links & bunch of other links (#1098)
* api/reddit: extract params from a mobile share link

* api/reddit: add support for a bunch of links & update the api endpoint

also fixed "undefined" in a filename when downloading a user post

* api/service-patterns: fix reddit id pattern
2025-02-10 00:17:48 +06:00
wukko
fb739f5315 merge: 10.7 api from main branch 2025-02-09 18:34:30 +06:00
wukko
5306760890 api/package: bump version to 10.7 2025-02-09 18:31:55 +06:00
wukko
6e653f468b api/instagram: add a filename to all single images 2025-02-09 18:23:28 +06:00
jj
55f591b37d api/instagram: add explanation for resolveRedirectingURL user-agent 2025-02-09 11:59:10 +00:00
jj
59cb6b05be api/test: add test for private instagram posts 2025-02-09 11:50:26 +00:00
wukko
20525d6c7c api/processing/url: sort imports by line length 2025-02-09 17:49:19 +06:00
wukko
5b63e2e6f2 api/instagram: sort imports by line length 2025-02-09 17:48:49 +06:00
wukko
b3b893b8f3 api/misc/utils: add one (1) line break 2025-02-09 17:48:37 +06:00
wukko
9d2f77949a api/tests/snapchat: revert story link change 2025-02-09 17:48:00 +06:00
wukko
98dbba5672 api/test: add reddit to finicky list cuz reddit blocked github ips 2025-02-09 17:42:10 +06:00
jj
3f6dd4fced api/youtube: expect errorInfo to not be json 2025-02-08 20:59:53 +00:00
jj
a918b12387 api/tests: fix broken tests 2025-02-08 20:59:27 +00:00
jj
a8cc5bc8bc api/instagram: update tests 2025-02-08 20:05:49 +00:00
jj
cca61275f1 api/instagram: add support for share urls
closes #998
2025-02-08 17:24:02 +00:00
jj
1be13a30bf api/instagram: age-restricted and private account-specific errors
fixes #222
2025-02-08 16:45:31 +00:00
jj
6d18dff5cc api/bilibili: use shortlink resolver 2025-02-08 16:27:33 +00:00
jj
bbcb2bee7c api/pinterest: use shortlink resolver 2025-02-08 16:09:49 +00:00
jj
5db5437b62 api/pinterest: fix undefined in name when downloading shortlink 2025-02-08 16:08:34 +00:00
jj
a758b1dbc6 api/snapchat: use shortlink resolver 2025-02-08 16:06:36 +00:00
jj
9e6582b76c api/xiaohongshu: use shortlink resolver 2025-02-08 16:05:51 +00:00
jj
6e8b4f30c1 api/url: add function for resolving shortlinks
motivation: we frequently need to resolve shortlinks to full URLs
let's have a common standard function for doing this safely
instead of reinventing the wheel in every single service module
2025-02-08 13:53:29 +00:00
jj
77dca70792 api/instagram: yet another attempt at resurrection 2025-02-07 22:47:36 +00:00
wukko
ce510a5746 web/layout: remove sidebar rounding on desktop 2025-02-07 18:51:06 +06:00
wukko
ca3263f1f3 web/layout: fix mobile nav bar gradient 2025-02-07 18:50:46 +06:00
wukko
adaf502d66 web: remove the early prototype of cutout functionality
at the time of this commit, there are no models that are good enough and can run in a web browser. this feature might come back when web onnx gets support for beefier models.
2025-02-07 16:55:28 +06:00
wukko
039ccf91be web/cutout: allow opening the page without extra settings 2025-02-07 16:48:10 +06:00
wukko
95d9913e3e web/Sidebar: always show cutout tab 2025-02-07 16:47:36 +06:00
wukko
dc33c07b39 web/storage: add clearCacheStorage function 2025-02-06 23:45:03 +06:00
wukko
1f79bf6e52 web/settings/advanced: add cache clearing, refactor data management 2025-02-06 23:44:05 +06:00
wukko
cff47da742 web/ProcessingQueue: add estimated storage usage 2025-02-06 22:56:05 +06:00
wukko
7a042e3bfa web/ProcessingQueue: clear old files from storage on page load 2025-02-06 22:28:08 +06:00
wukko
0ce777cbfc api/internal-hls: transform segment uri when probing the HLS tunnel 2025-02-06 14:29:42 +06:00
wukko
23f28acff0 web/i18n/error: update age-restriction & login errors 2025-02-05 19:23:29 +06:00
wukko
c8ea19a69c web/SettingsInput: fix z-index of input inner buttons 2025-02-05 19:09:37 +06:00
wukko
4f50b44e68 web/SettingsInput: make the clear button non-destructive
clear button now clears data only in the input box, not actual data

if you accidentally clear old data and don't save it, you can restore it with one click :3
2025-02-05 19:01:30 +06:00
wukko
c5d8d33870 web/SettingsInput: hide sensitive input & allow to show it with a button
also fixed padding & svg rendering in safari
2025-02-05 18:30:00 +06:00
wukko
62dccf7b51 web/SettingsInput: hide sensitive info (such as api key) 2025-02-05 17:07:29 +06:00
wukko
88d4b4dc7c web/ProgressBar: check if completedWorkers exists 2025-02-03 18:09:03 +06:00
wukko
1716c1d2af web/state/queue: check if pipeline exists before removing workers 2025-02-03 18:08:47 +06:00
wukko
6c18f1d460 web/ProcessingQueueItem: fix queue scroll 2025-02-02 14:45:31 +06:00
wukko
161b3a7e3c web/i18n/queue: update title 2025-02-02 02:28:31 +06:00
wukko
de5a2d10ca web/SectionHeading: reduce line height for beta tag 2025-02-02 02:08:50 +06:00
wukko
12ea601e6d web/state/queue: clean up result file when removing the task 2025-02-02 02:01:37 +06:00
wukko
c8ecf41b10 web/ProcessingQueueItem: fix stray space on error 2025-02-02 01:54:15 +06:00
wukko
945f87d93b web/libav: allow passing options to init 2025-02-02 01:53:59 +06:00
wukko
19a342457b web/storage: catch the missing dir error 2025-02-02 01:08:07 +06:00
wukko
61efa619a2 web/queue: fix filename on downloads, add mimetype, remove duplicates
filename is no longer passed to workers for no reason
2025-02-02 00:31:54 +06:00
wukko
50df95b212 web/queue: clear files from storage when needed 2025-02-02 00:15:44 +06:00
wukko
5464574a3e web/workers: use opfs instead of blobs for better memory management
spent almost an entire day figuring this out but it's so worth it
2025-02-01 23:26:57 +06:00
wukko
0a8323be54 web/tsconfig: add webworker lib 2025-02-01 22:49:21 +06:00
wukko
ee459e8694 web/layout: always display processing queue
because the remux page relies on it
2025-01-31 23:59:01 +06:00
wukko
90dcc48cad web/i18n/queue: update stub text 2025-01-31 23:54:41 +06:00
wukko
590b42a574 web/ProcessingQueueItem: fix processing-info overflow on mobile 2025-01-31 23:20:44 +06:00
wukko
ef08633bdb web/ProcessingQueueItem: mobile css fixes 2025-01-31 23:06:17 +06:00
wukko
00d376d4ac web/scheduler: break the global loop if current task is not done
i forgot to put break here, just blinded out that break on line 55 is breaking only its own inner loop
2025-01-31 22:08:57 +06:00
wukko
6513ab38d0 web/state/queue: clear all current tasks on queue clear 2025-01-31 22:02:35 +06:00
wukko
a7c1317af7 web/state/queue: clear pipeline results on error 2025-01-31 22:02:18 +06:00
wukko
2ae0fd01dd web/ProcessingQueue: use full progress per item, not just running task 2025-01-31 21:59:44 +06:00
wukko
398c5402d2 web/ProcessingQueueItem: display all steps in progress bar 2025-01-31 21:59:00 +06:00
jj
cdfb6e0fd9 web: bump libav remux version 2025-01-31 11:20:54 +00:00
wukko
1590490db2 web/queue: add a remux worker to saving pipeline, use pipelineResults 2025-01-31 11:22:31 +06:00
wukko
f2325bdc24 web/workers/remux: accept several files, custom args and output 2025-01-31 11:16:04 +06:00
wukko
7caee22aee web/scheduler: worker pipeline sequencing, file exchange between workers 2025-01-31 11:12:00 +06:00
wukko
d15f1ec8f2 web/workers/remux: differentiate remux worker file event 2025-01-30 18:58:02 +06:00
wukko
00106e9379 web/libav: accept several inputs, refactor 2025-01-30 18:48:45 +06:00
wukko
fd1a7530ed merge: api updates from main 2025-01-30 16:47:21 +06:00
wukko
b7997c220e web/i18n/queue: update stub text 2025-01-30 16:39:52 +06:00
jj
c48c64240b api/internal: allow redirects when reading chunks 2025-01-29 21:51:35 +00:00
wukko
5d7724762d web: very early implementation of a fetch worker 2025-01-30 01:04:33 +06:00
wukko
affe49474d api/readme: fix a typo in acknowledgments
an ability -> the ability
2025-01-29 16:43:12 +06:00
wukko
91f5d63b93 web/DownloadButton: extract api interaction logic into a lib
download button state is now stored, well, in a state
2025-01-29 16:35:43 +06:00
wukko
1c34d2daff merge: docs & test updates from main 2025-01-29 15:43:51 +06:00
wukko
b6472d5406 web: update h265 & gif params, migrate old params to new names 2025-01-29 15:40:29 +06:00
wukko
3a96c8ae56 docs/api: update h265 & gif params 2025-01-29 15:38:23 +06:00
wukko
e7d4b72c8c api/schema: tiktokH265 -> allowH265, twitterGif -> convertGif
h265 param is already used for more than tiktok, and gif param will be used for bluesky gifs in the future
2025-01-29 15:37:58 +06:00
wukko
a43e7a629b web: add local processing setting & api type
response is not handled at all yet, this is a raw draft
2025-01-29 15:06:16 +06:00
wukko
c7c9cf2f0f api: add local processing response type & param
`local-processing` type returns needed info for on-device processing and creates basic proxy tunnels
2025-01-29 15:00:50 +06:00
jj
75cda47633 web/libav: accept canonical extension if blob is a file 2025-01-25 20:13:23 +00:00
wukko
c5e7b29c6c web/ProcessingStatus: fix button focus ring 2025-01-26 02:13:09 +06:00
wukko
4f2c19b680 web/ProcessingQueue: indeterminate progress state 2025-01-26 02:06:37 +06:00
jj
af18bcd43f web/ProcessingQueue: include worker progress in global progress 2025-01-25 19:48:40 +00:00
wukko
7c3e1e6779 web/remux: remove fossil code & clean files after queue push 2025-01-26 01:40:18 +06:00
wukko
c3cc6c09f4 web/ProcessingQueueItem: state icons, localized strings, fix line break 2025-01-26 01:34:56 +06:00
wukko
73d2f45dae web/ProcessingStatus: make the button squishy 2025-01-26 00:57:56 +06:00
wukko
de66ac6b08 web/run-worker: subscribe to queue & kill worker when removed from store
& also clear the interval
2025-01-25 23:59:45 +06:00
wukko
d4684fa1f7 web/ProcessingQueueItem: break file title line anywhere 2025-01-25 02:10:44 +06:00
wukko
1e6b1cb201 web/ProcessingQueueItem: format file size to be readable 2025-01-25 02:06:50 +06:00
wukko
44a99bdb3a web/queue: add remuxing progress & general improvements
and a bunch of other stuff:
- size and percentage in queue
- indeterminate progress bar
- if libav wasm freezes, the worker kill itself
- cleaner states
- cleaner props
2025-01-25 01:25:53 +06:00
wukko
906d929333 api/tests/pinterest: update the gif link
because the id changed???
2025-01-23 22:00:02 +06:00
wukko
7b31817fdb api/tests/xiaohongshu: update photo test link 2025-01-23 21:58:41 +06:00
wukko
31f6ff9b87 api/tests/loom: update test links
the old video is unavailable for an unknown reason. it's unplayable in a regular browser and also loom's own landing page.
2025-01-23 21:51:06 +06:00
wukko
899d1efdea web/about/general: update infra partner phrasing 2025-01-22 14:46:30 +06:00
wukko
3be98a14b3 readme: update some phrasing & add a link to bluesky 2025-01-22 14:46:09 +06:00
wukko
99265d594b api/readme: update list of supported services & list of dependencies 2025-01-22 14:41:44 +06:00
jj
c4c47bdc27 merge: 10.6 updates 2025-01-21 13:36:37 +00:00
wukko
8d3db909d9 web/package: bump version to 10.6 2025-01-21 17:25:55 +06:00
wukko
cecb8a4c53 api/package: bump version to 10.6 2025-01-21 17:25:45 +06:00
wukko
36d4608ee5 api/bluesky: add support for tenor gifs 2025-01-21 17:18:49 +06:00
jj
ee3ef60a20 api/youtube: expect one of itags to be empty 2025-01-20 20:12:21 +00:00
wukko
0ab3fe4d2a api: itunnel transplants (#1065) 2025-01-21 00:10:49 +06:00
jj
600c769141 api/stream: implement itunnel transplants 2025-01-20 15:55:26 +00:00
jj
c07940bfa4 api/itunnel: pass itunnel object by reference 2025-01-20 15:46:03 +00:00
wukko
39752b2c5f web/Omnibox: improve pasting links from clipboard
- `text/uri-list` type is now accepted (such as clipboard data from bluesky)
- http links are now allowed (such as those from rednote)
- rednote share link is properly extracted
2025-01-20 21:26:55 +06:00
jj
19ade7c905 api/youtube: return internal metadata for replaying request 2025-01-20 14:47:09 +00:00
jj
7767a5f5bb api/youtube: add support for pinning client/itag 2025-01-20 14:46:55 +00:00
jj
035825bc05 api: cache original request parameters in stream 2025-01-20 14:38:55 +00:00
wukko
73f458a999 docs/api: update tiktokH265 description 2025-01-20 20:01:55 +06:00
wukko
9f0f885ae6 web/settings/video: update h265 toggle strings
because now it also applies to xiaohongshu
2025-01-20 19:59:59 +06:00
wukko
7488c74faf api/xiaohongshu: clean up the h265-h264 if statement
Co-authored-by: jj <log@riseup.net>
2025-01-20 19:46:12 +06:00
wukko
e39b0ae7b3 api/xiaohongshu: deduplicate h264 stream extraction
reduce() isn't called on 1 item arrays, so this is just fine

Co-authored-by: jj <log@riseup.net>
2025-01-20 19:41:02 +06:00
wukko
4963c9f128 api/xiaohongshu: remove duplicated extraction error
Co-authored-by: jj <log@riseup.net>
2025-01-20 19:37:23 +06:00
wukko
3cbed87c3e api/xiaohongshu: update initial state extraction regex
Co-authored-by: jj <log@riseup.net>
2025-01-20 19:35:53 +06:00
wukko
de5eca19a5 api/utils: replace redirectStatuses array with a set
Co-authored-by: jj <log@riseup.net>
2025-01-20 19:30:11 +06:00
wukko
cd0a2a47c9 api/tests/pinterest: update expected photo status 2025-01-20 19:28:35 +06:00
wukko
cd466a418a api/tests/bsky: fix expected photo test status 2025-01-20 19:24:12 +06:00
wukko
ad6f29a3c8 api/tests: add xiaohongshu tests 2025-01-20 19:21:44 +06:00
wukko
ed8f4353ea api/processing: add support for xiaohongshu 2025-01-20 19:10:02 +06:00
wukko
63b2681017 api/match-action: always proxy photos 2025-01-20 19:04:31 +06:00
wukko
9bdcb9d821 api/utils: update getRedirectingURL to accept more statuses & dispatcher 2025-01-20 18:51:37 +06:00
jj
ec0d773792 api/youtube: use Math.min instead of ternary operator 2025-01-20 12:38:12 +00:00
jj
0378a1ae15 api/youtube: fix error when downloading stuff from WEB 2025-01-20 12:37:36 +00:00
wukko
192635f2ce web/cutout: accommodate for updated file receivers 2025-01-19 03:00:03 +06:00
wukko
2279b5d845 web: core system for queue & queen bee, move remux to new system
it's 3 am and i think i had a divine intervention
2025-01-19 02:57:15 +06:00
wukko
ef687750b4 api/tiktok: update domain because dns records for main one are gone
closes #1057
2025-01-18 17:02:24 +06:00
jj
2273bb388f web/vite: split transformers.js into separate chunk 2025-01-16 20:42:58 +00:00
wukko
8a5b25b4ce web/removebg: fix the incorrect file condition 2025-01-17 01:51:10 +06:00
wukko
b85771dc1d web/removebg: differentiate messaging even more, add temporary logging 2025-01-17 01:45:11 +06:00
wukko
cc3e3be118 web/cutout: fix canvas visibility 2025-01-17 01:25:52 +06:00
wukko
28eb9ebe5d web/remux: improve page <-> worker messaging 2025-01-17 01:16:51 +06:00
wukko
8e9347b4a0 web/removebg: fix functionality after build, improve pipeline
- no longer killing the worker if it has done its job correctly and is expected to shut itself down
- no longer reading messages not intended for the worker handler and also made the cobalt messaging distnict
2025-01-17 01:03:59 +06:00
wukko
2812960088 web/cutout: reset the page state if the worker breaks 2025-01-16 13:46:52 +06:00
wukko
f544768784 web/cutout: add a button to cancel the job 2025-01-15 23:14:29 +06:00
wukko
0e26424355 web/libav: remove environment import to fix the worker 2025-01-15 22:25:59 +06:00
wukko
1ed2eef65a web/remux: convert to a web worker (wip) 2025-01-15 22:11:08 +06:00
wukko
28d8927c08 web/removebg: convert to a proper web worker
no more hanging ui :3
2025-01-15 17:22:34 +06:00
wukko
2f2d39dc4c web/removebg: fix types (remove garbage) 2025-01-14 18:30:33 +06:00
wukko
d649a00718 web/Sidebar: fix bottom padding on desktop 2025-01-14 18:25:43 +06:00
wukko
302ff4ff29 web/sidebar/CobaltLogo: fix padding 2025-01-14 18:21:16 +06:00
wukko
e02e7f2260 web: very early proof-of-concept of on-device image background removal 2025-01-13 01:26:54 +06:00
jj
2b95af1b51 merge: fix for tiktok audio download from picker 2025-01-12 17:14:12 +00:00
wukko
a892a37c53 web/layout: remove rounded corners on sidebar in dark theme 2025-01-12 22:58:59 +06:00
wukko
abc4673af7 web/sidebar: reduce padding on desktop & fix mobile padding 2025-01-12 22:55:10 +06:00
wukko
f816fae6ba web/layout: increase sidebar contrast in dark theme 2025-01-12 22:49:03 +06:00
jj
ce7d553beb api/match-action: pass audio bitrate when creating tiktok stream
fixes #996
2025-01-12 16:43:55 +00:00
wukko
2272bb5edd web/save: reduce terms note size on desktop 2025-01-12 22:37:49 +06:00
wukko
f0e67fb69f web/Omnibox: reduce omnibox gap 2025-01-12 22:37:06 +06:00
wukko
c8bd08a290 web/PageNavTab: remove redundant bg 2025-01-12 19:12:41 +06:00
wukko
0749106b96 web/SidebarTab: never break the tab name line 2025-01-11 21:07:44 +06:00
wukko
4b5fd1cda0 web/PopoverContainer: fix popover z-index 2025-01-08 17:55:50 +06:00
wukko
a6069f406f api & web: merge base queue ui & api updates 2025-01-08 17:20:00 +06:00
wukko
50db4d342a api & web: roll back the default hls change due to doubled CPU usage 2025-01-08 11:22:05 +06:00
wukko
7db31851d0 api/package: bump version to 10.5.3 2025-01-08 10:58:49 +06:00
wukko
b47987754a web/settings/defaults: enable youtubeHLS by default (again) 2025-01-08 10:56:59 +06:00
wukko
ec019a1b50 api/schema: enable youtubeHLS by default 2025-01-08 10:54:07 +06:00
wukko
937fddf3e9 web/settings/defaults: roll back default hls
it seems to be doing more bad than good, we need to scale or finish the duck project first
2025-01-07 13:16:58 +06:00
wukko
f07ebaa04c web/settings/defaults: enable youtubeHLS by default
yolo #testinprod
2025-01-06 15:38:58 +06:00
jj
0f65165671 api/package: bump version to 10.5.2 2024-12-27 17:17:08 +00:00
jj
a14e51d8bd api: uninstall esbuild
also not used for a while anymore
2024-12-27 15:18:57 +00:00
jj
ac3716ae4a api: uninstall node-cache package
not used for a while anymore
2024-12-27 15:16:49 +00:00
jj
45e7b69937 api/tunnel: add Content-Disposition to exposed headers 2024-12-25 20:05:18 +00:00
wukko
38823ecb22 web/changelogs/10.5: rephrase some sentences 2024-12-24 00:24:59 +06:00
wukko
1dc3532c5d api/package: bump version to 10.5.1 2024-12-23 23:34:14 +06:00
wukko
4d634603e2 api/youtube: fix variable shadowing
oops
2024-12-23 23:34:05 +06:00
wukko
c6be689453 web/changelogs/10.5: fix reference to latest commit 2024-12-23 23:23:43 +06:00
wukko
41430ff0da web/package: bump version to 10.5 2024-12-23 23:22:19 +06:00
wukko
a3166df03b web/changelogs: fix 10.5 changelog version 2024-12-23 23:22:11 +06:00
wukko
7f7281d794 api/package: bump version to 10.5 2024-12-23 23:21:44 +06:00
wukko
b998425c7e web/changelogs/10.4: fix the reference to twitter 2024-12-23 23:16:05 +06:00
wukko
328bfeb416 web/changelogs: add a 10.4 changelog 2024-12-23 23:06:21 +06:00
wukko
6b49bce595 web/layout: add more padding and a separation line to h2 in long text 2024-12-23 23:03:35 +06:00
wukko
00c4531011 web/ChangelogEntry: increase max banner height 2024-12-23 23:02:34 +06:00
wukko
c6d0e0bdd5 api/youtube: use poToken, visitorData, and web client with cookies
and also decipher media whenever needed, but only if cookies are used
2024-12-23 22:58:16 +06:00
jj
9da3ba60a9 api/youtube: add support for cookies 2024-12-23 11:11:48 +00:00
wukko
806a644a40 web/ProcessingStatus: replace icon with a more fitting one 2024-12-22 23:10:33 +06:00
wukko
41600dab4f web/settings/advanced: add a toggle for local processing 2024-12-22 23:04:37 +06:00
wukko
a9515d376a web/settings: add duck to settings types 2024-12-22 23:04:20 +06:00
jj
999fa562e0 web: bump version to 10.4.6 2024-12-22 14:10:51 +00:00
jj
537d1e8b61 api: bump version to 10.4.7 2024-12-22 14:10:31 +00:00
jj
1ed7e74773 api/match-action: pass isHLS when muting audio
fixes a bug where HLS status would be ignored if a muted video
was downloaded with HLS enabled
2024-12-22 14:09:16 +00:00
jj
52b7f9523f api/stream: remove content-length estimation from proxy() 2024-12-20 16:35:40 +00:00
jj
78d0670f50 api/stream: stfu deepsource 2024-12-17 12:20:17 +00:00
jj
06c348126e api/stream: remove random undici import
wtf
2024-12-17 12:16:04 +00:00
jj
fec07d0e10 api: add cors headers for tunnels 2024-12-16 17:45:02 +00:00
jj
f5b47a2b7e api/tunnel: adjust estimate multiplier to 1.1 2024-12-16 17:42:39 +00:00
jj
6e6a792984 api/bilibili: mark tunnel as isHLS where appropriate 2024-12-16 17:41:38 +00:00
jj
05e0f031ed api/stream: add Estimated-Content-Length header to tunnels
present where Content-Length cannot be accurately calculated,
pure proxy streams do not have this header and instead have
the accurate Content-Length one.
2024-12-16 17:07:30 +00:00
jj
11388cb418 api/stream: await all call types 2024-12-16 16:21:38 +00:00
jj
bf4675a5e3 api/stream: move bsky override into isHlsResponse 2024-12-16 11:29:13 +00:00
jj
bc597c817f api: move itunnel handlers to separate file 2024-12-16 10:38:31 +00:00
jj
f06aa65801 api: always create separate server for itunnels 2024-12-16 10:19:15 +00:00
jj
e7c2872e40 api/stream: rename getInternalStream to getInternalTunnel 2024-12-16 10:16:48 +00:00
wukko
5820736a31 web/ProcessingQueue: use the heading component with the beta tag 2024-12-19 21:11:02 +06:00
wukko
06000cbc77 web/SectionHeading: added a new prop to disable the link 2024-12-19 21:09:51 +06:00
wukko
8c9f7ff36d web/ProcessingQueue: align buttons to center vertically 2024-12-18 18:42:34 +06:00
wukko
73d0b24aaf web/layout: move processing queue into content for better a11y 2024-12-18 17:57:07 +06:00
wukko
5860efa620 web/PopoverContainer: hide for screen readers when not expanded 2024-12-18 17:48:40 +06:00
wukko
f3ff3656ef web/ProcessingQueue: fix ui on narrow screens 2024-12-18 17:47:48 +06:00
wukko
eba8dc3767 web/ProcessingQueue: make the clear button actually clear the queue 2024-12-18 17:10:30 +06:00
wukko
3f46395bd2 web/state/queue: add nukeEntireQueue() 2024-12-18 17:10:08 +06:00
wukko
a8bb64ffb1 web/ProcessingQueue: use new types and states, refactor
- added a dedicated ui debug button
- fixed scrolling (content is no longer cutting off weirdly)
- moved stub to its own component
- moved all permanent strings to localization
2024-12-18 17:04:57 +06:00
wukko
13ec4f4faf web/queue: add types & states 2024-12-18 16:59:08 +06:00
wukko
fcab598ec4 web/ProcessingStatus: make the icon thinner 2024-12-18 16:58:26 +06:00
wukko
11e3d7a8f4 web: rename DownloadManager to ProcessingQueue
also replaced the download icon with a blender (to be updated, maybe)
2024-12-17 16:50:13 +06:00
wukko
13c4438a57 web/DownloadManager: item component & type 2024-12-17 01:25:02 +06:00
wukko
45434ba751 web/UpdateNotification: accommodate space for the download manager 2024-12-16 18:05:39 +06:00
wukko
6d0ec5dd85 web: basic ui for the download queue manager 2024-12-16 18:03:55 +06:00
wukko
5d75ee493d web/SupportedServices: use the general popover component 2024-12-16 17:24:05 +06:00
wukko
91327220a0 web/PopoverContainer: create a reusable popover component 2024-12-16 17:23:43 +06:00
wukko
4cdbb02de2 web/SupportedServices: speed up the secondary expand by ~200μs 2024-12-16 00:25:45 +06:00
wukko
2e4b76de6e api/package: bump version to 10.4.6 2024-12-16 00:04:58 +06:00
wukko
1da7ad7a98 web/package: bump version to 10.4.5 2024-12-16 00:04:43 +06:00
jj
459b2c8283 api/internal-hls: don't remake chunk istreams if already wrapped 2024-12-15 17:59:47 +00:00
wukko
d8cfb78047 web/layout: adjust opacity of popover glow in dark mode 2024-12-15 00:24:54 +06:00
wukko
689d7b4846 web/DonateOptionsCard: hide the scroller for aria, not all options 2024-12-14 13:07:30 +06:00
wukko
35d9917301 web/SupportedServices: render popover only when needed
& also focus it for screen readers
2024-12-14 12:51:00 +06:00
wukko
89f197375c web/SupportedServices: better glow in dark mode 2024-12-14 12:42:38 +06:00
wukko
b44410e93b web/SupportedServices: springy expand animation 2024-12-14 12:30:04 +06:00
wukko
86a67dee83 api/package: bump version to 10.4.5 2024-12-13 16:03:32 +06:00
wukko
3dafdd825a api/types/proxy: use default dispatcher instead of a global one
this function never gets anything but internal streams, so global proxy (`API_EXTERNAL_PROXY`) is only causing issues here. this commit fixes an issue of cobalt attempting to proxy internal streams, and failing spectacularly.
2024-12-13 16:01:16 +06:00
wukko
5973d70053 api/package: bump version to 10.4.4 & update youtube.js 2024-12-12 23:03:00 +06:00
wukko
5eb411bb83 web/package: bump version to 10.4.4 2024-12-12 23:01:32 +06:00
wukko
994ce84483 web/error: add the error for temporarily disabled youtube 2024-12-12 23:01:05 +06:00
wukko
112866096c api/url: return a diff error when youtube is disabled on main instance 2024-12-12 23:00:49 +06:00
jj
f1916cef6e web: add automatic sitemap generation 2024-12-10 16:14:20 +00:00
wukko
e041e376c7 api & web: bump dependencies 2024-12-10 19:55:43 +06:00
wukko
4b8b0a0e9e api/youtube: don't retrieve the player as cobalt doesn't use it
we don't decipher anything lol
2024-12-10 17:30:32 +06:00
wukko
e1b84e7472 api/package: bump version to 10.4.3 2024-12-05 00:27:53 +06:00
jj
6f0a8196ff api/istream: remove icy-metadata header if sent by client 2024-12-04 18:25:25 +00:00
jj
6c39edbc10 api/stream: use dispatcher if passed to istream 2024-12-04 18:17:13 +00:00
wukko
6ca377ded6 api/tiktok: catch unavailable post error 2024-12-04 12:28:05 +06:00
wukko
569c232b47 web/i18n/settings: update description of "reduce transparency" toggle 2024-11-29 12:29:44 +06:00
wukko
0e5914f66c api/package: bump version 10.4.2 2024-11-28 17:53:35 +06:00
wukko
3126acc08e web/package: bump version to 10.4.2 2024-11-28 17:53:25 +06:00
wukko
15a0ba30c7 api/tests/vk: add new domain test 2024-11-28 17:32:41 +06:00
wukko
4700682ccb api/vk: refactor quality picking 2024-11-28 17:32:10 +06:00
wukko
f696335278 api/vk: use proper api, add support for more links, refactor
also added support for video access keys
2024-11-28 16:01:26 +06:00
wukko
5ffc0c6161 web/i18n/error: add string for api.service.audio_not_supported 2024-11-28 15:49:15 +06:00
wukko
50344eda17 api/match-action: proper error code for unsupported audio extraction 2024-11-28 15:48:18 +06:00
wukko
eee9beef91 api/create-filename: don't require author for pretty title 2024-11-28 15:47:30 +06:00
jj
55c97f77b8 api/cookie: reformat console.error in getCookie 2024-11-26 14:24:54 +00:00
jj
58edad553e api/cookie: replace name exception with console log
much easier to debug when writing a service
2024-11-26 14:05:13 +00:00
jj
fbacb94495 api/cookie: do not recreate interval if it already exists 2024-11-26 14:02:16 +00:00
jj
a4cb6ada79 api/cookie: split initial load into separate function 2024-11-26 14:01:36 +00:00
jj
20074a5091 api/cookie: rephrase non-string warning 2024-11-26 13:55:18 +00:00
jj
00ac025235 api/cookie: warn if writing updated cookies fails 2024-11-26 13:52:20 +00:00
jj
3d95361c09 api/cookie: validate cookie file format 2024-11-26 13:51:49 +00:00
jj
31d65c9fb7 api/cookie: validate service names for cookies 2024-11-26 13:44:51 +00:00
wukko
d7ae13213e web/i18n/settings: rename debug to nerd mode
and also update description for it
2024-11-26 18:34:13 +06:00
wukko
d4bcb1ba61 api/service-config: add new domains for vk 2024-11-26 18:21:44 +06:00
wukko
5be8789576 web/PageNavTab: flip the chevron in rtl layout 2024-11-25 12:24:09 +06:00
wukko
e93aa54e2f web/SavingDialog: fix weird focus border in chromium browsers 2024-11-25 12:22:28 +06:00
wukko
47804f462c web/i18n/error: update private & age post errors 2024-11-24 19:29:53 +06:00
wukko
e2f0123418 api/tests/tiktok: add an age restricted video test 2024-11-24 19:26:59 +06:00
wukko
a1fa79f2f5 api/tikok: catch an age restriction error 2024-11-24 19:26:44 +06:00
wukko
1559ed13af web/package: bump version to 10.4.1 2024-11-24 19:08:52 +06:00
wukko
2433681d8b api/package: bump version to 10.4.1 2024-11-24 19:08:40 +06:00
wukko
8a24dbb42d api/match-action: fix audio in tiktok picker
it didn't have an audio format in the filename, so it either failed or downloaded without an extension.

closes #870
2024-11-24 19:02:10 +06:00
wukko
cdd349cfb6 api/tests/rutube: add a region locked video test 2024-11-24 18:44:07 +06:00
wukko
6039eae6a3 api/rutube: catch a region lock error
closes #930
2024-11-24 18:43:50 +06:00
wukko
2ed52a161e web/i18n/error: add general content region & paid errors 2024-11-24 18:35:57 +06:00
wukko
9b0e4ab0bd api/tests/soundcloud: add tests for region locked and paid songs 2024-11-24 18:35:32 +06:00
wukko
43c3294230 api/soundcloud: catch region locked and paid songs and show an error 2024-11-24 18:35:07 +06:00
wukko
eb52ab2be8 api/vimeo: return accidentally remove merge function 2024-11-24 18:19:56 +06:00
wukko
1cbffc2d75 api/stream/types: convert metadata in one place
also sanitize values & throw an error if tag isn't supported
2024-11-24 18:13:22 +06:00
wukko
6770738116 api/create-filename: build & sanitize filenames in one place 2024-11-24 18:12:21 +06:00
wukko
407c27ed86 api/utils: rename metadata converter function 2024-11-24 14:55:46 +06:00
wukko
6a430545d2 api/utils/cleanString: add more forbidden chars 2024-11-24 14:55:10 +06:00
wukko
da5cd3e324 web/DonateBanner: optimize for rtl layouts 2024-11-24 14:30:02 +06:00
wukko
7fc3d70d71 web/remux: fix scroll on short screens 2024-11-24 14:19:40 +06:00
wukko
b737dbacd6 web/i18n/error: add api key errors 2024-11-24 14:08:06 +06:00
wukko
d8f3bbe0f3 web/lib/api: return errors from authorization function 2024-11-24 13:37:36 +06:00
wukko
6bb412852d api/package: bump version to 10.4 2024-11-24 00:37:52 +06:00
wukko
4ca94aa2cd web/package: bump version to 10.4 2024-11-24 00:37:40 +06:00
wukko
b1392cdc03 web/settings/instances: update access key section id 2024-11-24 00:33:36 +06:00
wukko
57734822ea web/settings/migrate: refactor, migrate to v4 schema
why the fuck was tab 2 spaces here
2024-11-24 00:23:06 +06:00
wukko
0b6270e745 web/SettingsInput: better screen reader accessibility
aria-label is now read instead of placeholders, cuz lengthy ones like uuid are a sensory overload and could confuse people. instead, now we make a fake ui placeholder (because there's no other way to have exclusively aria-label while also showing placeholder normally)
2024-11-24 00:12:35 +06:00
wukko
6129198024 web/settings/instances: always display the access key section 2024-11-23 23:22:47 +06:00
wukko
adb1cacd9d web/i18n/settings: update access key description 2024-11-23 23:22:08 +06:00
wukko
a9831a40a3 web/SettingsInput: fix uuid support & refactor 2024-11-23 23:21:54 +06:00
jj
326bc52f27 web: fix turnstile/server-info circular dependency 2024-11-23 14:37:23 +00:00
wukko
d4044e3350 web/server-info: remove turnstile in more cases 2024-11-23 19:14:14 +06:00
wukko
601597eb15 web: add support for custom api keys & improve turnstile states 2024-11-23 19:13:23 +06:00
wukko
7c7cefe89b web/settings: add a reusable SettingsInput component 2024-11-23 19:11:19 +06:00
wukko
8415d0e4f3 web/i18n/error: update invalid jwt token error 2024-11-23 19:08:41 +06:00
wukko
baebeed488 web/settings/v4: add api key settings, remove override settings 2024-11-23 19:08:24 +06:00
wukko
5b60065c9f web/about/terms: update the abuse email 2024-11-23 16:57:34 +06:00
wukko
ff9e248e4f api/util/test: add twitter to finnicky list
they seemingly blocked ips of github workers
2024-11-23 15:42:47 +06:00
wukko
7fa387b12f web/i18n/error: add youtube api error and update the login error 2024-11-23 15:38:33 +06:00
wukko
5b445d5c7e api/youtube: catch even more innertube errors 2024-11-23 15:37:42 +06:00
wukko
f1f9955159 web/i18n/error: rephrase a bunch of strings for more clarity and context
i didn't expect to rewrite this much ngl
2024-11-23 00:32:08 +06:00
wukko
1374693c2f web/Toggle: make the toggle stretchy 2024-11-20 16:06:48 +06:00
wukko
b8c1c1fe51 web/Toggle: remove accidentally committed bracket 2024-11-20 15:41:36 +06:00
wukko
c50cecae92 web/settings: replace advanced settings icon with a cooler one 2024-11-20 15:35:36 +06:00
wukko
c9833a358b web/layout: fix content rounded corners in RTL layout 2024-11-20 15:34:59 +06:00
wukko
620bd24243 web/PageNav: fix page padding in RTL layout 2024-11-20 15:34:37 +06:00
wukko
45e639a7e1 web/Sidebar: fix padding in RTL layout 2024-11-20 15:34:23 +06:00
wukko
88ed5876ae web/Omnibox: adapt for RTL layout 2024-11-20 15:34:10 +06:00
wukko
e7c2196a25 web/DownloadButton: adapt for RTL layout 2024-11-20 15:33:51 +06:00
wukko
72c30a58aa web/Switcher: fix rounded corners in RTL layout 2024-11-20 15:33:27 +06:00
wukko
94e5aad6c0 web/Toggle: accommodate for rtl layouts 2024-11-20 15:33:09 +06:00
wukko
6e81c55fc1 web: replace text-align: left with text-align: start
improves support for arabic and other RTL languages
2024-11-20 14:47:07 +06:00
wukko
9c8cb5611f web/server-info: reload the page only if the sitekey actually changed 2024-11-20 14:26:45 +06:00
wukko
1833a95027 web/PageNavTab: use icon prop instead of slot 2024-11-20 14:15:34 +06:00
wukko
a0616841bf web/DonationOption: use icon prop instead of slot 2024-11-20 14:15:03 +06:00
wukko
540bbbdad7 web/SidebarTab: pass icon prop instead of using slot 2024-11-20 14:14:37 +06:00
jj
7b9830c5af dockerfile: drop privileges to regular user 2024-11-19 14:20:15 +00:00
wukko
ea73d09c8f web/Turnstile: reduce retry interval to 800ms 2024-11-19 00:33:07 +06:00
wukko
a3c807a993 web/turnstile: use own callback for refreshing the widget
or at least try to, idk man, im so tired of cf turnstile
2024-11-19 00:20:27 +06:00
jj
b31c126cec api/instagram: fix module not using graphql api 2024-11-18 17:34:48 +00:00
wukko
6abccd9743 web/Turnstile: log to console on expired and timeout callback 2024-11-18 23:02:46 +06:00
wukko
c67132d2cc web/Omnibox: add a cool animation to input icons 2024-11-18 21:06:19 +06:00
wukko
b38cb77952 web/turnstile: refresh turnstile if it expires in background
also renamed `turnstileLoaded` to `turnstileSolved` for more clarity
2024-11-18 21:05:47 +06:00
wukko
e09e098b27 web/remux: reduce bullet padding only on small screens 2024-11-18 17:02:22 +06:00
wukko
a0b621c5e7 web/remux: increase bullet gap on desktop 2024-11-18 16:59:59 +06:00
wukko
778ee76d59 web/Omnibox: fix main instance domain check
oops
2024-11-18 16:42:59 +06:00
wukko
d8348dfa1c web: remove instance override warning, use custom api right away 2024-11-18 16:32:33 +06:00
wukko
2b2bc57331 web/env: rename apiURL to defaultApiURL
references to it are now easier to read and understand
2024-11-18 16:30:27 +06:00
wukko
4a70f09017 web/Omnibox: add community instance label
now it's easier for the end user to differentiate if an instance is official or not
2024-11-18 16:27:39 +06:00
wukko
277a6caefa web/ManageSettings: use downloadFile for exporting settings
and also use 4 spaces for formatting the json file cuz 2 spaces is foul
2024-11-18 15:44:32 +06:00
wukko
b036437871 web/i18n/general: update embed description to be less corny 2024-11-18 15:32:13 +06:00
wukko
6aade3cc78 web/BulletExplain: increase font size on desktop 2024-11-18 15:26:37 +06:00
wukko
b015af7dde web/remux: add bullet points explaining what remux is 2024-11-18 15:24:50 +06:00
wukko
152ba6d443 web/components: add BulletExplain component 2024-11-18 15:24:11 +06:00
wukko
26e051fcd8 api/package: bump version to 10.3.3 2024-11-16 22:29:32 +06:00
wukko
606f0fd29a api/stream/internal: workaround for wrong bsky content-type, refactor 2024-11-16 22:15:13 +06:00
wukko
b61b8c82a2 api/bluesky: use hls video cdn directly 2024-11-16 21:57:14 +06:00
wukko
09c66fead0 api/package: bump version to 10.3.2 2024-11-15 20:35:06 +06:00
wukko
3dc5f634cf web/package: bump version to 10.3.2 2024-11-15 20:34:53 +06:00
wukko
3de3e9e158 api: remove support for vine cuz the archive is dead
masterful gambit elon musk
2024-11-15 18:29:21 +06:00
jj
f7dc6cebad all: add space after catch 2024-11-15 12:19:49 +00:00
jj
4c006b2291 api/test: add vk to finnicky services 2024-11-15 12:11:29 +00:00
jj
cf40f0542f api/test: make deepsource happy 2024-11-13 17:27:26 +00:00
jj
f6bffe543c api/test: replace test.js with test-ci.js 2024-11-13 17:26:15 +00:00
jj
91e8ef8ab4 api/test-ci: add functionality for running all tests 2024-11-13 17:26:15 +00:00
jj
aaf7077364 api/test: split up tests into individual files 2024-11-13 17:26:15 +00:00
wukko
3203f5bb2f web/SupportedServices: better popover animation 2024-11-13 23:24:50 +06:00
KwiatekMiki
0e09bf9895 api/service-config: recognize facebook's mobile subdomain (#891) 2024-11-13 22:35:45 +06:00
wukko
3fe2bd3b7c api/youtube: add missing else to adaptive codec fallback 2024-11-13 22:23:45 +06:00
wukko
225a721805 api/tests: allow vk tests to fail 2024-11-13 18:48:36 +06:00
wukko
dec977e34d api/youtube: fix variable shadowing in normalizeQuality 2024-11-13 18:45:18 +06:00
wukko
c88e21d4a8 api/youtube/adaptive: refactor, avoid extra loops, fallback all codecs 2024-11-13 18:41:57 +06:00
wukko
c05f40b279 web/i18n/error: fix punctuation in no matching format error 2024-11-13 15:05:20 +06:00
wukko
e9d06b77a8 web/i18n/error/youtube: add no format error & improve hls error 2024-11-13 15:02:10 +06:00
wukko
5f1c19d0f1 api/youtube: add no matching format error
this error is returned when cobalt got a response from innertube, but couldn't find a matching combo of video and audio streams. sometimes youtube returns only video or only audio per format combo for whatever reason.
2024-11-13 15:00:09 +06:00
wukko
8b972c7a85 api/youtube: disable hls if user prefers av1 2024-11-13 14:50:13 +06:00
wukko
b6e827c6f9 api/youtube: improve video quality normalization once again 2024-11-13 14:49:51 +06:00
wukko
8fc9ca2916 api/bluesky: add a dispatcher & update unknown error message 2024-11-11 12:23:53 +06:00
jj
e3f6784e83 web/about/privacy: replace html link with markdown link 2024-11-09 17:02:02 +00:00
wukko
f50bd6339b api/service-config: add support for loom embed links 2024-11-07 20:53:25 +06:00
wukko
5a418bd9c6 web/changelogs/10.3: update commit range 2024-11-06 17:41:01 +06:00
wukko
c021293780 web/changelogs/10.3: fix typo in "language" 2024-11-06 17:16:08 +06:00
jj
ab653e4533 CONTRIBUTING: mention comment feature 2024-11-05 18:28:06 +00:00
jj
c27466e247 CONTRIBUTING: mention lowercase text in translations 2024-11-05 18:14:38 +00:00
wukko
44fe585a89 web/layout: fix paragraph title alignment in about tab
oops
2024-11-05 12:46:18 +06:00
wukko
57501e834e web/changelogs/10.3: limit the comparison commit range 2024-11-05 00:56:00 +06:00
wukko
23eefe2f41 web/changelogs/10.3: update image alt text 2024-11-05 00:47:09 +06:00
wukko
857ac06435 web/changelogs: add 10.3 changelog 2024-11-05 00:45:25 +06:00
wukko
2300f5c0af web/package: bump version to 10.3 2024-11-05 00:43:58 +06:00
wukko
2b7fcabf87 web/ChangelogEntry: reduce banner min height 2024-11-05 00:43:20 +06:00
wukko
cecdbda7e4 web/layout: update long text heading styling & add table styling 2024-11-05 00:43:03 +06:00
jj
c09347f18b CONTRIBUTING: add info about localization platform 2024-11-04 18:08:48 +00:00
wukko
c477b728e1 web/about/community: add a link to bluesky 2024-11-04 21:26:38 +06:00
jj
f4ca4ea719 web/settings: validate youtubeDubLang as literal 2024-11-03 20:02:43 +00:00
hyperdefined
160160704d docs/run-an-instance: add missing DISABLED_SERVICES (#882) 2024-11-03 17:29:42 +01:00
jj
5a7635cdf7 api/cookie: write cookies only if from-file cookie was changed 2024-11-02 18:48:26 +00:00
jj
c44a5ecc89 api/cookie: fix cookie.set() being ran only once 2024-11-02 18:46:56 +00:00
wukko
b88abdd94b ci/docker: remove armv7 as build platform
fuck fucking armv7 FUCKKKKK
2024-11-02 23:39:46 +06:00
jj
7fbb7ee5e6 dockerfile: switch to alpine 2024-11-02 16:23:55 +00:00
wukko
ca665c5382 api: replace psl with homegrown & up-to-date fork
finally no more punycode warning
2024-11-02 21:19:19 +06:00
wukko
37517875db api/package: update dependencies 2024-11-02 20:18:59 +06:00
wukko
eb84aecebc dockerfile: update the image to debian 12
because there's no debian 11 armv7 image
2024-11-02 18:04:21 +06:00
wukko
d4b8400146 web/FileReceiver: reduce padding & gaps 2024-11-02 17:40:32 +06:00
jj
e2b4141fc7 api/memory-store: unref timeout so it doesn't hold up process 2024-11-02 11:33:21 +00:00
wukko
ab3af731e7 api/package: bump version to 10.3 2024-11-02 17:19:28 +06:00
jj
cba308aabd api/test-ci: reduce stream lifespan
the streams have picked up smoking
2024-11-02 11:13:04 +00:00
jj
2f89f79b14 api/memory-store: ; 2024-11-02 11:12:39 +00:00
wukko
44e08e8474 api/config: separate error if statements for session & instance count 2024-11-02 16:48:34 +06:00
wukko
541bf04575 api/services: fix createStream calls in pickers
oops
2024-11-02 16:43:36 +06:00
jj
382873dc11 api/core: fix main cluster being unable to handle itunnels 2024-11-02 09:59:48 +00:00
jj
676bc9879c docker: bump node version to 23 2024-11-01 17:52:46 +00:00
jj
5a66af514e api: make deepsource happy 2024-11-01 17:24:22 +00:00
jj
90d57ab6ea api/config: store tunnelPort in env 2024-11-01 17:02:29 +00:00
jj
d48cc8fc07 api/cookie: implement cluster synchronization 2024-11-01 16:43:01 +00:00
jj
42ec28a642 api/cookie: update cookies value-by-value in manager 2024-11-01 14:58:04 +00:00
jj
f098da870c api/cookie: pick cookie at random instead of round-robin 2024-11-01 14:55:00 +00:00
jj
1c78dac7ed api/cluster: implement broadcast helper 2024-11-01 14:49:52 +00:00
jj
2351cf74f4 api/cookie: formatting 2024-11-01 14:05:18 +00:00
jj
48883486fa api/api-keys: load keys once per cluster 2024-11-01 13:57:53 +00:00
jj
3f505f6520 api: wait for cluster to finish preparing 2024-11-01 13:30:32 +00:00
jj
2317da5ba5 api: add support for redis to ratelimiter cache 2024-11-01 13:26:18 +00:00
jj
d466f8a4af api: upgrade express-rate-limit to v7, reuse key generator 2024-11-01 12:54:16 +00:00
jj
693204b799 api/store: use basic strings instead of hashes for keys 2024-11-01 12:20:01 +00:00
jj
66cb8d360d api: move hmac secrets to single file 2024-11-01 12:16:53 +00:00
jj
40d6a02b61 api: cluster support
still missing synchronization for some structures
2024-10-31 22:59:06 +00:00
jj
2d6d406f48 api/crypto: use buffers for salt directly instead of hex strings 2024-10-31 22:42:46 +00:00
jj
93e6344fc7 api/stream/manage: make itunnel port configurable
this allows us to bind internal streams to
a specific worker in the future
2024-10-31 22:35:26 +00:00
jj
132255b004 api/stream/manage: use cobalt Store for stream cache 2024-10-31 22:33:32 +00:00
jj
11314fb8d1 api/store: implement has() method 2024-10-30 19:21:45 +00:00
jj
18acad19b9 api: implement redis/memory store for cache 2024-10-30 19:06:46 +00:00
jj
5e92b649a3 api: add API_REDIS_URL env 2024-10-30 18:59:20 +00:00
wukko
0508c2305c readme: rephrase the thank you section 2024-11-01 19:56:10 +06:00
wukko
9cc2df9efd docs/docker-compose: revamp the template, add read_only
we use `read_only` on the main instance and i think everyone else should use it too
2024-11-01 19:48:12 +06:00
wukko
2c451c69d0 api/youtube: rename quality variable in matchQuality 2024-10-31 21:43:02 +06:00
wukko
3dd6165472 api/youtube: slight refactor of matchQuality 2024-10-31 21:37:11 +06:00
wukko
5470926d52 api/youtube: adjust matched resolution
heights like 714 are now adjusted to 720, so that preferred quality is picked correctly
2024-10-31 21:31:39 +06:00
wukko
da72b9615e api/youtube: use best quality if all else fails 2024-10-31 21:18:34 +06:00
wukko
98acea6c58 api: bump version to 10.2.1 2024-10-31 00:14:26 +06:00
wukko
6322c172c1 ci/test: update api test url 2024-10-31 00:10:22 +06:00
wukko
776c4f4dba api/stream/manage: don't use clones in node cache 2024-10-30 23:56:14 +06:00
wukko
406ac7613c api/youtube: make sure language exists when checking for hls dubs
oops
2024-10-30 22:55:50 +06:00
wukko
8f89c7f412 web/i18n/settings: update youtube setting titles and descriptions 2024-10-30 22:38:38 +06:00
wukko
904e5aa918 web/video: update youtube codec & hls section ids 2024-10-30 22:37:55 +06:00
wukko
8840396865 web/audio: update youtube dub section id 2024-10-30 22:37:45 +06:00
wukko
fb2b0ad290 web/i18n/settings: update youtube hls toggle title 2024-10-30 22:06:00 +06:00
wukko
d16118ed42 web: bump version to 10.2.1 2024-10-30 21:56:30 +06:00
jj
c4be1d3a37 web/download: don't try to open non-https links 2024-10-30 13:17:38 +00:00
jj
b125894b7e web/settings: move migration to separate file, rename v7 migration 2024-10-30 12:42:52 +00:00
wukko
44f842997e api & web: bump version to 10.2 2024-10-30 18:29:53 +06:00
jj
0a471943ca web/settings: write to storage if migrated 2024-10-30 12:18:27 +00:00
jj
30b7003871 Revert "web/settings/migrate: remove youtubeDubBrowserLang migration"
This reverts commit 94e6acb832.
2024-10-30 12:18:27 +00:00
jj
cafe05d5fb web/settings: add version 3 of setting schema 2024-10-30 12:18:27 +00:00
jj
ec10019bfa web/settings: fix types, migrate old settings from v2 2024-10-30 12:12:56 +00:00
jj
bad59750bf web/settings: rewrite type names, remove unused types
CurrentCobaltSettings -> CobaltSettings
CobaltSettings -> AnyCobaltSettings
2024-10-30 12:12:56 +00:00
jj
7c9a824a69 web/settings: add function for getting browser language
prep for migrating youtubeDubBrowserLang
2024-10-30 12:12:56 +00:00
jj
7a50c89728 web/settings: split settings into versions 2024-10-30 12:12:56 +00:00
wukko
edb340dc66 web/i18n/settings: update reduce transparency description
added that enabling it may also improve ui performance on low end devices
2024-10-30 18:01:43 +06:00
wukko
c3a2386086 docs/api: add one more example of language codes for youtubeDubLang 2024-10-30 17:53:10 +06:00
wukko
94e6acb832 web/settings/migrate: remove youtubeDubBrowserLang migration 2024-10-30 17:19:51 +06:00
wukko
6e61e73a5f web/i18n/settings: rewrite youtube hls description 2024-10-30 17:18:18 +06:00
wukko
367cab0de4 api/youtube: update hls vp9 container to webm
way better compatibility this way
2024-10-30 17:18:18 +06:00
wukko
f610058b82 api/stream/types/merge: encode audio to aac or opus if hls
audio is encoded to opus only if it's a youtube hls stream with webm container
2024-10-30 17:18:18 +06:00
jj
b9a44f81a0 ci/web: run type check before building 2024-10-30 11:13:36 +00:00
wukko
1e5b30778d web/settings/audio: add a beta tag to youtube dub section 2024-10-28 23:21:46 +06:00
wukko
ce131b1454 web/settings/privacy: remove beta tag from tunneling 2024-10-28 23:18:04 +06:00
wukko
ea2dd5bb35 web: add support for dubbed youtube audio tracks 2024-10-28 23:15:01 +06:00
wukko
1373d16286 web/SettingsDropdown: add a separator after first item, always lowercase
also split out anything in brackets in preview
2024-10-28 23:14:19 +06:00
wukko
e081751c59 api/youtube: fix dubbed audio track matching 2024-10-28 23:05:56 +06:00
wukko
3a0b0fed8b web/settings: convert LanguageDropdown to universal SettingsDropdown 2024-10-28 21:42:07 +06:00
wukko
17c020fe22 api/youtube: fix dubbed hls audio marking 2024-10-28 21:38:25 +06:00
wukko
486555bd11 docs/api: add youtubeHLS and remove youtubeDubBrowserLang 2024-10-28 19:57:37 +06:00
wukko
0b4d703d0f api/utils: remove unused functions 2024-10-28 19:56:37 +06:00
wukko
cdfc91844d api/schema: update youtubeDubLang to accept all valid language codes 2024-10-28 19:56:18 +06:00
wukko
b14c618228 api/youtube: pick a default track for videos with ai dubs 2024-10-28 19:35:08 +06:00
wukko
9f9300ebb8 web/i18n/settings: rephrase audio format description 2024-10-28 18:30:18 +06:00
wukko
14ca47b73d api/youtube: make mp3 the best format for hls audio 2024-10-28 18:30:01 +06:00
jj
53e6085095 api/stream: don't override content-length for hls transform 2024-10-28 11:55:15 +00:00
wukko
6b1eadbe09 api/util/tests: add youtube hls tests 2024-10-28 16:59:50 +06:00
wukko
866427a7a7 api/youtube: fix local variable overlap 2024-10-28 16:55:44 +06:00
wukko
effec1bfb9 api/youtube: return correct audio url in hls mode
my disappointment in its quality is immeasurable
2024-10-28 16:45:48 +06:00
wukko
0ddb3e3ecc api/match-action: add isHLS to audio stream info 2024-10-28 16:45:30 +06:00
wukko
3ed51c9eeb web/i18n/error: add youtube hls error 2024-10-28 15:45:32 +06:00
wukko
fba6ba09c2 api/youtube: add hls codec fallback, update hls error code, refactor
also fixed best audio format
2024-10-28 15:45:18 +06:00
wukko
60b22cb5f7 web: add support for youtube hls
also increased api response timeout to 20 seconds
2024-10-28 15:27:51 +06:00
wukko
c9eefc4d55 api/youtube: add an option to use HLS streams
- added `youtubeHLS` variable to api
- added youtube HLS parsing & handling
2024-10-28 15:17:54 +06:00
wukko
24ae08b105 api/stream: add isHLS to stream cache 2024-10-28 15:15:41 +06:00
wukko
a46e04358a api/match-action: rename isM3U8 to isHLS and u to url 2024-10-28 15:14:36 +06:00
wukko
7c516c0468 api/cookie/manager: pass cookiePath to writeChanges()
also reordered functions to maintain the hierarchy
2024-10-28 12:08:12 +06:00
wukko
7798844755 api/youtube: refactor, fix fallback, don't repeat same actions
fallback to h264 is now done if there's no required media, not only if adaptive formats list is empty.

best audio and best video are now picked only once.
2024-10-28 12:01:38 +06:00
jj
7dc0121031 api: defer file loads until api is running 2024-10-27 18:12:59 +00:00
jj
b434b0b45e api/cookies: log message to confirm successful file load 2024-10-27 18:12:01 +00:00
jj
5a5a65b373 api/cookies: trigger cookie load from api entrypoint 2024-10-27 18:10:57 +00:00
jj
af50852815 api/api-keys: log message to confirm successful file load 2024-10-27 18:00:05 +00:00
jj
5ea23bee13 api/console-text: refactor 2024-10-27 17:52:04 +00:00
KwiatekMiki
b22d0efbf1 api/service-patterns: recognize older streamable links (#862) 2024-10-27 18:34:11 +01:00
jj
c463e3eabb ci: run codeql on all branches 2024-10-27 19:18:15 +01:00
jj
a4e6b49d7f util/jwt: ensure uniform distribution of characters 2024-10-26 18:28:25 +00:00
jj
d8b7a6b559 api/test: remove youtube vp9 test
we fall back to h264 now, so this will always succeed
2024-10-26 18:08:43 +00:00
jj
2ccc210622 api/test: add test for audio download if no video found
tests for bug fixed in fb7325f3b2
2024-10-26 18:07:15 +00:00
wukko
fb7325f3b2 api/youtube: more refactoring, return audio even if there's no video 2024-10-26 23:53:43 +06:00
wukko
66bb76e1c7 web/i18n/settings: update preferred language description 2024-10-26 23:06:43 +06:00
wukko
8b15fe7863 api/youtube: check if playability is ok after the status switch 2024-10-26 22:49:16 +06:00
wukko
3907697fa7 web/i18n/settings: rephrase the youtube codec desc
also added info about fallback
2024-10-26 22:45:16 +06:00
wukko
52c1714608 web/i18n/settings: fix typo in youtube codec description 2024-10-26 22:38:42 +06:00
wukko
cfb05282c3 api/youtube: refactor, fallback codecs, don't return premuxed videos 2024-10-23 19:56:59 +06:00
wukko
ae271fd3c6 api/youtube: refactor playability status handling 2024-10-23 18:08:50 +06:00
wukko
a3ee3d9c16 api/youtube: catch one more age limit error 2024-10-23 14:01:10 +06:00
wukko
9d59a2f5d2 web/about/terms: point out even more that safety email is not support 2024-10-22 14:16:10 +06:00
jj
1b9855206e docs/configure-for-youtube: omit run from pnpm command 2024-10-20 23:12:35 +02:00
jj
429b7c85aa docs/configure-for-youtube: change pnpm command 2024-10-20 23:12:07 +02:00
wukko
4b1ea6ed80 docs/protect-an-instance: update the template secret to fail 2024-10-20 20:18:50 +06:00
jj
4efe6d9350 api/config: disallow JWT_SECRETs shorter than 16 chars 2024-10-20 14:15:08 +00:00
wukko
43b3139b4a docs/protect-an-instance: skip second step of api keys config if remote 2024-10-20 19:53:17 +06:00
wukko
9790179e29 docs/protect-an-instance: add api keys configuration 2024-10-20 19:51:35 +06:00
wukko
a81a19de68 docs/protect-an-instance: add a command for generating a secret 2024-10-20 19:26:19 +06:00
wukko
16c5450d40 api/cobalt: update api url error message 2024-10-20 19:07:42 +06:00
wukko
9d68247523 api: remove the outdated setup script 2024-10-20 19:06:48 +06:00
wukko
155322a47b docs/configure-for-youtube: clarify where to put the token 2024-10-20 18:59:07 +06:00
wukko
f33cf12fd3 docs/run-an-instance: update headings 2024-10-20 18:56:37 +06:00
wukko
6933daf046 docs: add configure-for-youtube document 2024-10-20 18:56:23 +06:00
jj
c17db15e62 web/debug: dump states on debug page 2024-10-20 12:51:59 +00:00
jj
be7c09bd07 web/lib: move dialogs to state folder 2024-10-20 12:51:59 +00:00
jj
4c43a00e88 web/api/session: replace writable with normal variable 2024-10-20 12:51:59 +00:00
wukko
a58684f314 docs/protect-an-instance: update the tuto value warning 2024-10-20 18:05:50 +06:00
wukko
722223f6d3 docs/protect-an-instance: fix image alignment 2024-10-20 18:02:24 +06:00
wukko
b837f291b5 docs/protect-an-instance: fix image sizes, add a secret warning 2024-10-20 17:59:38 +06:00
wukko
6499d079ef api/readme: add supported services & acknowledgements 2024-10-20 17:49:37 +06:00
wukko
71c3d64331 repo: update contribution guidelines 2024-10-20 17:45:37 +06:00
wukko
c494850cff repo: update readme & remove old docs 2024-10-20 17:45:10 +06:00
wukko
51adfc85cd api: update readme 2024-10-20 17:20:38 +06:00
wukko
67ffcdc504 docs/api: update the general api warning 2024-10-20 16:52:59 +06:00
wukko
7515204bb7 docs/api: update warnings 2024-10-20 16:51:38 +06:00
jj
c3f3499a42 api/util: add script to generate secure JWT_SECRET 2024-10-20 10:44:13 +00:00
wukko
5ce3a941f9 docs/protect-an-instance: emphasize a warning in env variable section 2024-10-20 16:31:55 +06:00
wukko
90114bdbea docs/protect-an-instance: update the note to show up as such 2024-10-20 16:28:22 +06:00
wukko
1cf82e4d69 docs: add a tutorial document for protecting an instance 2024-10-20 16:23:09 +06:00
jj
f5d09f86db tests/soundcloud: replace private link 2024-10-20 10:18:51 +00:00
jj
d55dddea2e core/api: normalize bearer authorization 2024-10-20 10:05:34 +00:00
wukko
0e52e1f8b0 web/safety-warning: reduce continue button timeout 2024-10-16 17:03:34 +06:00
wukko
1ab94eb11d web/i18n: update data management strings 2024-10-16 16:53:20 +06:00
wukko
c33017283d api/twitter: fix gifs having a wrong file extension in a picker 2024-10-13 09:59:52 +06:00
dumbmoron
eab37ae7ff web/dialog: show dialog when loading cobalt with no js support 2024-10-12 18:01:57 +00:00
dumbmoron
0b06299da0 web/DialogButton: add "link" buttons 2024-10-12 17:42:53 +00:00
wukko
fe1d17ba8d api/service-patterns: update the tiktok tester 2024-10-12 23:29:19 +06:00
wukko
ef4dd4875e web/icons/Clipboard: increase color contrast 2024-10-12 23:15:29 +06:00
wukko
c8ab784385 web/icons/Music: make colors brighter 2024-10-12 23:06:14 +06:00
wukko
4499992d58 web/icons/Sparkles: update colors for better legibility 2024-10-12 22:54:44 +06:00
wukko
72483bbdad web/icons/Mute: update colors for better legibility 2024-10-12 22:49:36 +06:00
wukko
6c3b4e0fa9 web/AboutSupport: update github color & add glow 2024-10-12 22:23:01 +06:00
wukko
6ad838b649 api/tiktok: fix url patterns 2024-10-12 22:06:54 +06:00
wukko
0d2e300fbe web/about/credits: add a section about imput 2024-10-12 19:20:20 +06:00
wukko
c10652b8c4 web/AboutSupport: replace duplicated type 2024-10-12 19:10:31 +06:00
wukko
d5ea154ed8 web/Omnibox: reduce gap by 2px 2024-10-12 19:08:01 +06:00
wukko
e34b8dd89c web/Switcher: add a gap between items 2024-10-12 19:07:05 +06:00
wukko
ebf157862a web/about/community: redesign the page, add descriptions 2024-10-12 19:06:11 +06:00
dumbmoron
6cc895c395 docs/api: document /session endpoint 2024-10-12 12:36:48 +00:00
dumbmoron
52c24ab1a3 docs/run-an-instance: add undocumented turnstile envs 2024-10-12 12:36:48 +00:00
dumbmoron
1c9685922f docs/api: add information about auth header 2024-10-12 12:36:48 +00:00
dumbmoron
7c0fb16fdb api/keys: fix prefix size calculation for individual ipv6 addresses 2024-10-12 11:24:29 +00:00
dumbmoron
9f4f03ec6c docs/examples/cookies: add youtube_oauth to examples 2024-10-12 11:06:19 +00:00
Alec Armbruster
dc12d6acad web/debug: add a copy button, fix page padding, refactor (#782)
Co-authored-by: wukko <me@wukko.me>
2024-10-11 23:04:19 +06:00
wukko
1e26788a1e api/match-action: add missing ok case to video switch
closes #797
2024-10-08 16:09:08 +06:00
KwiatekMiki
1b48a2218c api/setup: use pnpm instead of npm 2024-10-06 16:11:23 +02:00
wukko
c482c9fea2 web/layout: do iphone landscape optimizations only when appropriate 2024-10-06 00:20:14 +06:00
wukko
3749fb2aa8 repo: update dependencies 2024-10-05 22:09:00 +06:00
wukko
e12e079571 web/SettingsCategory: prevent pointer events when disabled 2024-10-05 21:42:02 +06:00
lath
4156206f35 web/settings/audio: disable bitrate section when not applicable (#802) 2024-10-05 21:40:56 +06:00
jj
4ed2df64b3 api: implement support for api keys (#803) 2024-10-05 17:14:55 +02:00
dumbmoron
3691e2e4f1 docs/run-an-instance: mention unlimited api keys 2024-10-04 17:43:35 +00:00
dumbmoron
cfd54e91d5 security/api-keys: add support for unlimited limit 2024-10-04 17:41:05 +00:00
dumbmoron
9cc6fd13fa api/core: skip turnstile verification if user authed with api key 2024-10-04 17:37:57 +00:00
dumbmoron
3d7713a942 security/api-keys: clarify error when number is not positive 2024-10-04 17:34:15 +00:00
dumbmoron
81818f8741 api/core: implement authentication with api keys 2024-10-04 16:50:55 +00:00
dumbmoron
dcd33803c1 api/core: generate JWT rate limiting key in auth handler 2024-10-04 17:03:57 +00:00
dumbmoron
418602ca87 api/core: add rate limiter for session 2024-10-04 17:02:00 +00:00
dumbmoron
38fcee4a50 api/core: rename tunnel limiter, move to endpoint 2024-10-04 17:00:58 +00:00
dumbmoron
f2248d4e9a api/core: move api limiter after authentication 2024-10-04 16:59:53 +00:00
dumbmoron
034f7ebe4a api/core: extract rate limit response to function 2024-10-04 16:58:15 +00:00
dumbmoron
44f7e4f76c web: remove TURNSTILE_KEY env from readme 2024-10-04 15:19:19 +00:00
dumbmoron
741dfd40f5 api/security: implement api keys as method of authentication 2024-10-04 14:58:56 +00:00
dumbmoron
4317b128a8 about/credits: move beta tester listing to component
this is to prevent it from showing up in i18n
2024-10-04 12:27:34 +00:00
Alec Armbruster
1a9494b60a web/layout: increase toggle contrast in dark mode (#754) 2024-10-04 17:43:31 +06:00
lath
c2d7e1df12 api/config: add configuration for streamLifespan (#792) 2024-10-03 12:27:28 +06:00
KwiatekMiki
b3137ad9ac feat/api: add support for twitter bookmark links (#706)
* feat: add support for twitter bookmark links

* feat: add tests for bookmark twitter links
2024-10-03 12:26:38 +06:00
wukko
e419de07a4 web/layout: fix text selection color 2024-10-03 11:57:18 +06:00
wukko
16997f1e38 web/about/credits: add the website link for one of testers 2024-10-02 18:47:30 +06:00
wukko
d7c2415f38 web/changelogs/10.1: fix a typo in "readability" 2024-10-01 23:26:47 +06:00
wukko
9f9ab36e7e web/changelog/10.1: update the hash in the compare link 2024-10-01 23:17:26 +06:00
wukko
f461b02fcd web/changelogs/10.1: update the github compare link 2024-10-01 23:14:43 +06:00
wukko
1f7dc6f54f web/changelogs: add 10.1 changelog 2024-10-01 23:09:11 +06:00
dumbmoron
e0a65a5bc4 NotchSticker: fix sticker support for newer iphone models 2024-10-01 17:02:48 +00:00
wukko
485353add1 web/layout: reduce ul margin in long text noto components 2024-10-01 22:51:42 +06:00
wukko
85bfb6535e web/ChangelogEntry: allow saving banners on right click 2024-10-01 21:15:57 +06:00
dumbmoron
eaf87dc9a2 web/changelogs/10: update banner 2024-09-30 19:39:10 +00:00
wukko
d3fb71f52f web/changelogs/10: update banner alt text 2024-10-01 01:15:56 +06:00
wukko
2db04b87b6 web/changelogs: update cobalt 10 banner 2024-10-01 01:10:23 +06:00
wukko
7922fd7257 web/about/credits: swap meowbalt and testers sections 2024-09-30 22:41:40 +06:00
wukko
84aa9fe67a web/about/credits: add a section for thanking beta testers 2024-09-30 22:39:52 +06:00
wukko
31be60484d web/DonateOptionsCard: add 5px of tolerance for max position
fixes right stepper not hiding itself in chrome when manually scrolled to the end
2024-09-30 21:54:38 +06:00
dumbmoron
b4dd506f61 svelte/csp: add forgotten frame-ancestors directive to config 2024-09-30 14:31:44 +00:00
wukko
391a8950c5 web/about/terms: clarify that safety email is not for support 2024-09-28 18:14:10 +06:00
wukko
4a89831753 web/about/general: rephrase descriptions to deliver the point better 2024-09-28 18:10:18 +06:00
wukko
24bc50793a web/donate: rewrite motivation text to convey the message better 2024-09-28 17:41:57 +06:00
wukko
bf7a48a36c api/youtube: fix youtube music metadata parsing
still pretty crappy tho
2024-09-28 02:01:43 +06:00
wukko
7d6fe34fa4 web/SupportedServices: don't allow selection when popover is hidden 2024-09-27 22:03:58 +06:00
wukko
80d01a7d29 web/DonateOptionsCard: shorten processor note & remove mobile text 2024-09-27 21:50:39 +06:00
wukko
6e3755ae3a web/DonateOptionsCard: rename monthly to recurring 2024-09-27 21:43:08 +06:00
wukko
3ceef9565d web/DonateOptionsCard: adjust options mask size 2024-09-27 21:35:19 +06:00
wukko
f528919072 web/DonateShareCard: optimize qr size 2024-09-27 21:35:02 +06:00
wukko
5307e86bce web/DonateCardContainer: reduce button padding 2024-09-27 21:33:45 +06:00
wukko
4f6d94d8e0 web/DonateShareCard: increase the shadow when expanded 2024-09-27 21:21:27 +06:00
wukko
fede942a3f web/DonateCardContainer: reduce padding 2024-09-27 21:16:05 +06:00
wukko
ebf2d493aa web/DonateOptionsCard: update buttons on wheel too 2024-09-27 21:01:41 +06:00
wukko
6ba27f8369 web/DonateOptionsCard: add scroll buttons to the options container
cuz users without touchpads couldn't scroll it without tabbing
2024-09-27 20:54:09 +06:00
wukko
5a4be4890b web/about/general: add motivation section & rephrase summary 2024-09-27 18:19:54 +06:00
wukko
6e80703aa7 10.1: bug fixes, ui & self-hosting improvements, better security (#775) 2024-09-23 23:00:01 +06:00
wukko
2a42ed38b6 repo: merge new commits from main into develop 2024-09-23 20:27:32 +06:00
wukko
416a9efdd1 web/server-info: reload the page if turnstile sitekey changes 2024-09-23 16:16:17 +06:00
wukko
f8a6b533be web/svelte.config: update img-src csp again 2024-09-23 15:30:31 +06:00
wukko
1460ee0d53 web/_headers: remove redundant async 2024-09-23 15:23:23 +06:00
wukko
e0132ab928 web/PickerItem: add urlType to downloading params 2024-09-23 15:18:20 +06:00
wukko
402b4b6485 web/types/api: fix formatting 2024-09-23 15:11:58 +06:00
wukko
ba93492c8d web: prevent openURL action on ios devices if url is redirect 2024-09-23 15:06:57 +06:00
wukko
12f7ee874e web/svelte.config: fix img-src csp 2024-09-23 15:04:17 +06:00
wukko
d9f1134f7f web/SidebarTab: make the icon bigger and gap smaller 2024-09-22 21:41:21 +06:00
wukko
c9c1e5d298 web/layout: add padding to about heading 2024-09-22 21:03:50 +06:00
wukko
44f470f192 web/PageNav: reduce the page width in wide mode 2024-09-22 20:55:53 +06:00
wukko
7160be65bd web/i18n/error: fix typo in live video error 2024-09-22 16:25:31 +06:00
wukko
af337cbfce web/error: make youtube codec error easier to understand 2024-09-22 16:23:38 +06:00
wukko
490bdb729e web/Omnibox: add aria label for loading captcha state 2024-09-22 16:22:18 +06:00
wukko
1473f220cb web/SectionHeading: make the link button always visible
scaling and 40 letter german words will be the death of me
2024-09-22 15:42:25 +06:00
wukko
128a1ff696 web/ManageSettings: add wrapping (oops) 2024-09-22 15:28:03 +06:00
wukko
2bee3e896d web/SectionHeading: fix weird wrapping 2024-09-22 15:21:23 +06:00
wukko
a5c704c5f0 web/PageNavTab: fix cursor appearance 2024-09-22 15:15:19 +06:00
wukko
a7b61dd24c web/SidebarTab: fix double scale on press, hold, release 2024-09-22 15:15:07 +06:00
wukko
dfaef913c4 web/DownloadButton: move server info cache checks to the api lib 2024-09-22 15:05:40 +06:00
dumbmoron
f83537a73e tests/bsky: fix tests & use dids instead of usernames 2024-09-21 13:24:40 +00:00
dumbmoron
8ae48fa524 api: allow colons (:) in url paths 2024-09-21 13:24:39 +00:00
dumbmoron
5ba83f3d56 web/polyfills: add polyfill for AbortSignal.timeout 2024-09-21 09:08:56 +00:00
wukko
819c7a4fa0 web/DownloadButton: check server info before main request 2024-09-20 18:28:35 +06:00
wukko
92008d3012 web/Omnibox: hide the clear button if request is processing 2024-09-20 15:22:29 +06:00
wukko
c0bb637480 web/DownloadButton: show a message about ongoing antibot check 2024-09-20 15:20:53 +06:00
wukko
c99240339d web/Omnibox: allow input while antibot check is ongoing & fix spinner 2024-09-20 15:20:25 +06:00
wukko
8162877a47 web/i18n/settings: update preferred language description 2024-09-19 17:04:04 +06:00
dumbmoron
d560c0d34a api: return correct extension for gif downloads in api response 2024-09-18 18:03:04 +00:00
wukko
7ba56f85be web/SectionHeading: fix line height of beta tag 2024-09-18 21:42:09 +06:00
wukko
a6b940e6c9 api/package: bump version to 10.1.0 2024-09-18 21:24:24 +06:00
wukko
2cb9735b28 web/package: bump version to 10.1.0 2024-09-18 21:24:13 +06:00
wukko
643e9775f5 web/DonationOptionsCard: move href inside the option button 2024-09-18 20:57:52 +06:00
wukko
9ea6b09e7e web/PageNav: add fade in animation for subtitle 2024-09-18 20:30:35 +06:00
wukko
ce054e63fc web/settings: improve settings section ids 2024-09-18 20:23:29 +06:00
wukko
b30b6957ce web/package: move dependencies to devDependencies 2024-09-18 20:15:56 +06:00
wukko
026cb634ec web: update & move csp to svelte.config.js
ough
2024-09-18 20:11:47 +06:00
wukko
52599dd900 web/headers: update csp yet again
whatever dude
2024-09-18 19:16:23 +06:00
wukko
9024418aff web/headers: add more stuff to CSP again 2024-09-18 19:12:13 +06:00
wukko
732199332e web/headers: fix CSP directives & refactor 2024-09-18 19:06:46 +06:00
wukko
97977efabd web: generate _headers & add Content-Security-Policy header 2024-09-18 18:44:24 +06:00
wukko
d1686be583 web/i18n/about: replace section titles with i18n strings 2024-09-18 17:41:10 +06:00
wukko
02267b4db4 web/i18n/about: use section heading component 2024-09-18 16:17:22 +06:00
wukko
521eb4b643 web/Sidebar: remove fixed width for tabs container 2024-09-18 15:58:32 +06:00
wukko
c92cd6d21c web/SidebarTab: improve animations & adjust mobile style 2024-09-18 15:46:07 +06:00
wukko
1a845fcfc2 web/SectionHeading: reusable component for linkable section headings 2024-09-18 15:28:09 +06:00
dumbmoron
503514d98e web/vite: exclude .md files from i18n chunks 2024-09-17 21:53:46 +00:00
dumbmoron
d2b1a6553b web/about: fix switching between pages 2024-09-17 21:49:23 +00:00
dumbmoron
a1361e8462 web/about: convert pages to translatable markdown 2024-09-17 18:54:36 +00:00
wukko
fdd5feac92 web: use turnstile & session only when the processing instance has them
now also always fetching server info in the save tab
2024-09-18 00:24:54 +06:00
wukko
0cc18b488c api/core: return public turnstile sitekey in server info 2024-09-17 22:40:07 +06:00
dumbmoron
29f967a3ec api: fix accept & content-type validation when not using authentication 2024-09-17 15:37:21 +00:00
dumbmoron
5e7324bca9 web/SettingsCategory: add copy link to settings header 2024-09-17 14:06:56 +00:00
wukko
baddb13470 web/i18n/settings: update language section descriptions 2024-09-17 19:48:59 +06:00
wukko
39eca27e53 web/changelogs/10: lowball the user count estimate 2024-09-17 14:14:43 +06:00
Alec Armbruster
b04c204492 web: fix spelling for various tenses of tunnel (#755)
https://github.com/imputnet/cobalt/pull/755
2024-09-16 17:33:40 +02:00
dumbmoron
66479a9791 web/translations: add fallback locale name to unnamed locales 2024-09-16 15:13:44 +00:00
dumbmoron
d93e97e06b web/LanguageDropdown: unbind locale from select dropdown 2024-09-16 15:13:24 +00:00
dumbmoron
86268eab3f api-client: add dist folder to gitignore 2024-09-16 13:45:58 +00:00
lath
1bf0d98324 web/DonateShareCard: fix copy button not using i18n (#750) 2024-09-16 11:14:29 +06:00
GuriZenit
99937f61f6 api/setup: fix wrong misc path 2024-09-16 11:05:04 +06:00
wukko
0ccd08470b web/about/general: more clarity in privacy section 2024-09-13 23:57:18 +06:00
wukko
5facbc9657 api/tests/bluesky: update deleted post test 2024-09-13 23:28:14 +06:00
wukko
47625490ce web/settings/video: move codec names away from i18n 2024-09-13 21:25:27 +06:00
wukko
9c2babfc1b docs/run-an-instance: teaching myself how to count to 6
sorry guys, it takes a ton of practice :(
2024-09-13 12:39:10 +06:00
wukko
f830a1219d docs/run-an-instance: update tutorial for running the api locally 2024-09-13 12:35:58 +06:00
wukko
a2414682c7 api/tests: update bluesky tests 2024-09-13 09:55:06 +06:00
wukko
a1feadb917 api/bluesky: add support for recordWithMedia embed type
& catch various api errors
2024-09-13 09:54:05 +06:00
wukko
474c8e284f web/i18n/settings: update av1 codec string 2024-09-12 20:08:20 +06:00
wukko
ca538a2e6c api/youtube: use webm container for av1 and opus 2024-09-12 20:07:56 +06:00
dumbmoron
acb4e49e18 ci: increase timeout on api sanity check 2024-09-12 08:40:40 +00:00
wukko
b90a58f4f0 api/tests/twitch: fix expected twitch status 2024-09-12 14:38:36 +06:00
wukko
e768e7f6fa api/create-filename: don't assign any of potentially blank tags 2024-09-12 14:35:42 +06:00
wukko
80a01494c7 api/match-action: add missing twitch case to redirect group
closes #741
2024-09-12 14:30:21 +06:00
wukko
64173f7a03 api/create-filename: don't push youtubeFormat if it doesn't exist
oops
2024-09-11 22:18:51 +06:00
dumbmoron
4af48dd2f9 web: add UserActivation polyfill for browsers that don't have it 2024-09-11 11:15:25 +00:00
wukko
2122e87e66 web/NotchSticker: fix sticking out on XR and 11 2024-09-11 16:41:04 +06:00
wukko
d7454a073b web/download: assume userActivation expired if agent doesn't support it 2024-09-11 14:42:54 +06:00
dumbmoron
6e3589c8ce web/changelogs: fix broken bullet lists 2024-09-10 19:19:16 +00:00
dumbmoron
6653fdc8df changelogs/6.0: fix broken link 2024-09-10 19:18:00 +00:00
wukko
fd23080dd9 web/i18n/settings: fix typo in video quality description 2024-09-10 09:07:46 +06:00
wukko
394c1d8eaf web/remux: add remux tag to the end of filename
closes #720
2024-09-10 08:45:53 +06:00
dumbmoron
2fcda9c705 web/ChangelogEntry: fix off-by-one changelog date in some cases 2024-09-09 20:35:12 +00:00
dumbmoron
2dc9d64092 docs/examples: update composefile example 2024-09-09 16:50:50 +00:00
dumbmoron
08cc36c873 ci: update action to use api package 2024-09-09 16:45:04 +00:00
dumbmoron
aa25421422 web/changelogs: update references from current to main 2024-09-09 16:38:47 +00:00
dumbmoron
f71f8b086d github: update links from current to main 2024-09-09 16:31:37 +00:00
wukko
08bc5022a1 cobalt 10: svelte web app, improved backend (#719) 2024-09-09 22:26:14 +06:00
wukko
0a5405a3b4 api/tests/twitter: update age restricted video test 2024-09-09 22:21:27 +06:00
wukko
2a1ebd8e69 repo/readme: update formatting & clean up 2024-09-09 22:12:33 +06:00
dumbmoron
e5c0bfa6f8 README: clarify root license 2024-09-09 16:00:23 +00:00
dumbmoron
a93e9f593f web: scope package name 2024-09-09 15:59:40 +00:00
dumbmoron
35456fe6d7 README: add bluesky to supported services 2024-09-09 15:52:12 +00:00
dumbmoron
a86a96d8ce docs/api: also link to old docs for now 2024-09-09 15:50:51 +00:00
dumbmoron
d6929b0758 web/README: clarify wording in license permission explanation 2024-09-09 15:46:55 +00:00
dumbmoron
99d4f9e8c9 api/README: remove leftover links 2024-09-09 15:45:06 +00:00
dumbmoron
7b06a5047f repo: split README into subproject READMEs 2024-09-09 15:44:37 +00:00
dumbmoron
8df4bc00cb web/svelte: change public prefix to WEB_ 2024-09-09 15:24:23 +00:00
wukko
ce7633c81a api: move agpl license into subdir 2024-09-09 21:21:13 +06:00
wukko
93ca553fb2 web/about/community: add community links 2024-09-09 21:19:43 +06:00
dumbmoron
1b639edac8 web/layout: specify full path in og:url 2024-09-09 15:04:25 +00:00
dumbmoron
15a90e9b11 api/stream: use Map for storing info about internal streams 2024-09-09 14:29:06 +00:00
dumbmoron
57c9836f56 api/core: move ratelimiters before authentication handler 2024-09-09 14:21:19 +00:00
wukko
3317726afe api/core: rename req and next args to indicate that they're unused 2024-09-09 19:44:40 +06:00
wukko
0aae3fe7f0 api/core: rename err argument to indicate that it's unused 2024-09-09 19:41:44 +06:00
wukko
de5162e417 api/jwt: remove redundant exports 2024-09-09 19:39:07 +06:00
wukko
ebd1104df3 web/libav: remove redundant async 2024-09-09 19:36:16 +06:00
wukko
bd93da94dc web/download: remove redundant async 2024-09-09 19:36:08 +06:00
wukko
2dbcdb18f9 api/bluesky: remove redundant await & async 2024-09-09 19:32:17 +06:00
dumbmoron
5210d62490 merge: merge changes from 7.x into 10 2024-09-09 13:16:46 +00:00
dumbmoron
6e523f300a web/donate: update sendCustom handler to use links 2024-09-09 13:00:06 +00:00
dumbmoron
3156831847 web/donate: use links instead of onclick handler 2024-09-09 12:57:58 +00:00
dumbmoron
fba64df118 api/snapchat: fix regex matching to thumbnail 2024-09-09 12:53:46 +00:00
wukko
0b02c22d9c web/changelogs/10: rephrase the list starter 2024-09-09 18:50:24 +06:00
wukko
498e6f4419 api/snapchat: fix spotlight url extraction 2024-09-09 18:40:59 +06:00
wukko
e2c8723c0e web/changelogs/10: add changelog banner 2024-09-09 18:27:50 +06:00
wukko
263e6a9321 web/changelogs/10: fix some deeplinks and phrasing 2024-09-09 17:08:52 +06:00
wukko
049edd49f1 web/layout: fix strong element weight 2024-09-09 17:05:37 +06:00
dumbmoron
4777d69cd7 web/OuterLink: don't set target/rel for relative links 2024-09-09 11:02:38 +00:00
wukko
6d95700075 web/changelogs: add proper 10.0 changelog 2024-09-09 16:59:16 +06:00
wukko
0569bb87a1 web/about: remove faq section 2024-09-09 15:42:25 +06:00
wukko
a84664c9d5 web/about/general: remove corny text 2024-09-09 14:32:23 +06:00
wukko
11b756945f web/about: thanks & licenses page 2024-09-09 14:24:11 +06:00
wukko
35254502fe web/about/general: add human section 2024-09-09 13:52:40 +06:00
wukko
2049e65221 web/about/general: update the community section 2024-09-09 13:39:30 +06:00
wukko
272b7a64b8 web/i18n/remux: make description clearer 2024-09-09 13:14:26 +06:00
wukko
fbe8ccfc2a web/download: show an explanation when user activation expires 2024-09-09 12:53:22 +06:00
wukko
dcbda243a2 web/i18n: update basic russian translation 2024-09-09 12:36:47 +06:00
wukko
d171e3c158 web: clean up blank pages & stray i18n strings 2024-09-09 12:24:32 +06:00
wukko
b96d2ea352 web: add og:title to head on all pages with custom title 2024-09-09 12:19:25 +06:00
wukko
0b5b8454a9 web/remux: return render instead of the original file
LMFAOOOOOOO
2024-09-09 12:05:13 +06:00
wukko
91d09a4e89 web/layout: remove og:title from head 2024-09-09 03:30:11 +06:00
wukko
8a18645e0b web/remux: warn user & terminate libav before switching tabs
warning about aborting processing will be shown before navigating away from remuxing tab
2024-09-09 03:08:18 +06:00
wukko
dd1c630c71 web/remux: download files properly 2024-09-09 02:30:20 +06:00
wukko
853bc26587 web/download: support downloading and sharing raw files 2024-09-09 02:30:03 +06:00
wukko
b1f41cae41 web/remux: tweak progress appearance 2024-09-09 01:17:34 +06:00
dumbmoron
2bcc849790 web/remux: fix file saving on ios 2024-09-08 18:59:51 +00:00
dumbmoron
2c75c52eb3 web/remux: narrow file type 2024-09-08 18:58:36 +00:00
wukko
fff4393c46 web/layout: revert position change for #cobalt 2024-09-09 00:26:20 +06:00
wukko
0f51b22d99 web/SettingsCategory: rename state from animate to focus 2024-09-09 00:10:53 +06:00
wukko
913beda417 web/DialogHolder: fix top padding 2024-09-09 00:10:35 +06:00
wukko
53eb052fe6 web/SavingDialog: add ios saving tutorial 2024-09-09 00:10:21 +06:00
dumbmoron
a613f1402d web/FileReceiver: define input in component to prevent losing it to GC 2024-09-08 16:55:58 +00:00
dumbmoron
a351264ede web/api: send parameters to server only if they differ from defaults 2024-09-08 16:30:02 +00:00
dumbmoron
1f86faad12 web/api: don't request/send session token to custom instances 2024-09-08 16:30:02 +00:00
dumbmoron
b4599e68bb web/safety-warning: early returns 2024-09-08 16:30:02 +00:00
wukko
002e70cb89 api/schema: make tiktokFullAudio false by default 2024-09-08 22:03:29 +06:00
wukko
cda99a96e8 web/DonateAltItem: add proper aria label 2024-09-08 20:47:33 +06:00
wukko
fb1b5ffee2 web/donate: fix horizontal scroll on mobile 2024-09-08 20:22:07 +06:00
wukko
1821b4b614 web/settings/defaults: set tiktokFullAudio to false 2024-09-08 20:19:12 +06:00
wukko
fcde8ad745 web/settings/migrate: don't migrate twitterGif 2024-09-08 20:18:50 +06:00
dumbmoron
894174bed9 web/layout: fix main content jumping when navigating in some cases 2024-09-08 11:42:04 +00:00
dumbmoron
d0bd70e213 web/SidebarTab: fix content jumping when navigating between distant tabs 2024-09-07 20:26:29 +00:00
dumbmoron
f9e80e6d6f web: add link header to improve imessage previews 2024-09-07 18:08:20 +00:00
wukko
30bcad0ba4 web/layout: add og:url & application name to head 2024-09-07 23:15:20 +06:00
dumbmoron
08c34762e9 test: run service tests only when api/packages change 2024-09-07 17:07:48 +00:00
wukko
7f94c73e6a web/html: change og:type to article 2024-09-07 23:06:45 +06:00
wukko
25a33a4ea2 web/layout: split description metadata tags 2024-09-07 23:02:13 +06:00
wukko
08113d7ae5 web/html: add embed type 2024-09-07 22:58:33 +06:00
wukko
e839aa4c41 web/types/settings: add 8kbps bitrate option back 2024-09-07 22:33:43 +06:00
wukko
a698c55663 web/layout: add og:description 2024-09-07 22:32:40 +06:00
dumbmoron
9a504443fd api/stream: add support for 8kbps option for mp3 downloads 2024-09-07 16:28:34 +00:00
wukko
c6385f1842 web/layout: add description and title metadata 2024-09-07 22:14:55 +06:00
dumbmoron
b4f17487b4 web: remove built-in sveltekit announcer (for real this time) 2024-09-07 16:01:26 +00:00
wukko
3fdfd44515 web/UpdateNotification: set an alert aria role 2024-09-07 21:16:26 +06:00
wukko
f406e7355b web/layout: get rid of svelte announcer
it reads over our own a11y labels, and there's no official way to disable it
2024-09-07 20:58:30 +06:00
dumbmoron
70ba8f8b39 web: replace apple-mobile-web-app-capable with mobile-web-app-capable 2024-09-07 14:48:47 +00:00
dumbmoron
6a67ed29ca web/remux: split memory allocations into chunks
browsers don't like to allocate huge chunks of contiguous memory, but
we do not actually need a huge chunk of contiguous memory, and this
lets us process much larger files than before
2024-09-07 14:35:36 +00:00
dumbmoron
0a37c84e93 web/libav: always clean up files on function exit 2024-09-07 14:35:36 +00:00
wukko
0ce743d13f web/about: replace the godawful margin workaround 2024-09-07 20:29:13 +06:00
wukko
f03f849b99 web/changelogs: add a placeholder changelog for 10.0 update 2024-09-07 20:10:42 +06:00
wukko
7a45866c7c web/about: write the general about page 2024-09-07 18:31:02 +06:00
dumbmoron
5af4114c61 api/test: add facebook to finnicky services 2024-09-07 12:29:01 +00:00
dumbmoron
08490c54e4 api: include generated filename in api response 2024-09-07 12:26:43 +00:00
dumbmoron
438fce3c58 api/processing: append audio extension to filename before response 2024-09-07 12:26:43 +00:00
wukko
c52aa76426 web/about: merge community and help tabs 2024-09-07 18:22:17 +06:00
wukko
7cd572954f web/remux: improve accessibility 2024-09-07 18:21:40 +06:00
dumbmoron
3232c4a51b web/libav: simplify reading probe data 2024-09-07 11:41:56 +00:00
wukko
3189857c77 web/layout: reduce margin even more for long text h3 heading 2024-09-06 16:59:37 +06:00
wukko
b710906404 web/i18n/donate: better language for body text 2024-09-06 16:59:19 +06:00
wukko
5a5c9da3df web/settings/advanced: improve settings management buttons
also removed duplicate localization strings
2024-09-06 16:18:30 +06:00
wukko
494522d292 web/subnav/PageNavSection: fix vertical overflow 2024-09-06 16:06:55 +06:00
wukko
1758e2db19 web/settings/audio: swap tiktok and youtube sections 2024-09-06 15:58:01 +06:00
wukko
16d59a239c web/types/settings: remove 8kbps option 2024-09-06 15:57:37 +06:00
wukko
348a28dd12 web/about/terms: rename and rewrite the last section 2024-09-06 15:45:59 +06:00
wukko
96c6897ae0 web/i18n/settings: improve descriptions
less yapping and more clarity
2024-09-06 15:45:18 +06:00
wukko
4db0665ab6 web/SupportedServices: aria label for the expand button 2024-09-06 15:27:02 +06:00
wukko
aa0991eee4 web/about/privacy: don't show plausible info if it's disabled 2024-09-05 23:34:30 +06:00
wukko
59b6cbd8d4 web/about: add info about terms being valid only on official instance 2024-09-05 23:33:33 +06:00
wukko
065b4394c9 web/i18n/donate: update motivation text a little 2024-09-05 16:06:41 +06:00
wukko
ec3e411032 web/about/privacy: specify cloudflare services 2024-09-05 13:27:57 +06:00
wukko
b1b8f6967b web/about/terms: initial terms and ethics page 2024-09-05 10:22:02 +06:00
wukko
152a423bf3 web/about/privacy: add info about on-device processing 2024-09-05 10:21:47 +06:00
wukko
cb7d1baee0 web/layout: update global heading text style in long text 2024-09-05 10:21:30 +06:00
wukko
4921e5c151 web/about/privacy: initial privacy policy page 2024-09-05 09:58:45 +06:00
wukko
0408fc446a web/layout: don't apply noto mono for titles 2024-09-05 09:58:25 +06:00
wukko
4da1defcf8 web/Omnibox: add missing clipboard input type 2024-09-05 09:06:03 +06:00
wukko
80e32fc0c0 web/remux: check if env is browser before adding listeners 2024-09-05 08:56:01 +06:00
wukko
21832005e2 web/remux: handle more errors, add a basic progress bar 2024-09-05 08:51:41 +06:00
wukko
7a5e60f39a web/SettingsCategory: use general beta label 2024-09-05 08:00:18 +06:00
wukko
b03c71eb14 web/SidebarTab: add aria label for beta tabs & fix its position 2024-09-05 07:59:49 +06:00
wukko
9e8c953ca6 web/Sidebar: mark remux tab as beta 2024-09-05 07:55:30 +06:00
wukko
97866fb306 api/core: update the emoticon in startup message 2024-09-05 07:53:55 +06:00
wukko
10d867efc0 web/i18n/error: add api at capacity error 2024-09-05 00:55:02 +06:00
wukko
fff1c6c7a6 web & api: rename stream to tunnel
- updated the endpoint to /tunnel
- updated status to tunnel
- fixed one incorrectly named error in web
2024-09-05 00:26:48 +06:00
wukko
49460bd16d web: load turnstile only in save tab and only once
turnstile will stay in background after being loaded once (just like before), but now it will not load on pages other than save if they are opened first
2024-09-05 00:04:41 +06:00
dumbmoron
f0f5d7be7e web/DonateOptionsCard: parametrize card processor string 2024-09-04 17:01:51 +00:00
dumbmoron
218916cee3 web/CopyIcon: change copy-animation id to class 2024-09-04 16:45:52 +00:00
wukko
8b067e363b web/DonateBanner: remove useless classes from the hearts bg svg 2024-09-04 22:40:42 +06:00
wukko
b9bb760793 web/DonateBanner: replace 150 svgs with background-repeat 2024-09-04 22:36:34 +06:00
dumbmoron
79237185bd docs/api: update url misspecification 2024-09-04 16:30:02 +00:00
dumbmoron
9800a9b54f docs/api: update to reflect new request/response schema 2024-09-04 16:28:59 +00:00
wukko
41c23337ff web/error: redirect to default about page on 404 error 2024-09-04 21:23:47 +06:00
wukko
201f9aaefe web/Sidebar: dynamic about page link 2024-09-04 21:23:07 +06:00
wukko
520725462a web: fix auto navigation on scale change for about & settings pages 2024-09-04 21:20:56 +06:00
wukko
140683a679 web/save: make the terms note open the terms page 2024-09-04 21:00:41 +06:00
wukko
d98353d5af web/about: about page routing & navigation 2024-09-04 20:59:05 +06:00
wukko
6a0c05cf7a web/PageNav: add a prop for making content wider 2024-09-04 20:58:51 +06:00
wukko
7e8ae2ca61 web/PageNav: add a prop for enabling content padding 2024-09-04 20:51:13 +06:00
wukko
56008676f5 web/settings: move sub navigation into its own component 2024-09-04 17:50:47 +06:00
wukko
a18fd72ea0 web/remux: reduce gap in first state 2024-09-04 16:47:00 +06:00
wukko
3c5a5eaf25 web/save: make terms note thicker 2024-09-04 16:33:45 +06:00
wukko
032f592d95 web/remux: reduce desc font size on mobile 2024-09-04 16:32:20 +06:00
dumbmoron
b7e5a94226 web/svelte: enable fallback page generation 2024-09-03 13:39:21 +00:00
dumbmoron
1262bc20fe web/version: don't try to fetch when server-side rendering 2024-09-03 13:33:16 +00:00
dumbmoron
f009da7de4 web: wrap error handler in onMount 2024-09-03 13:30:32 +00:00
dumbmoron
93f8c038d2 web/Turnstile: remove unused import 2024-09-03 13:24:15 +00:00
dumbmoron
b5d570c43f web/Turnstile: check if already defined before making listener 2024-09-03 13:21:41 +00:00
dumbmoron
fc26032048 web: fix destructuring error if theme is undefined 2024-09-03 13:04:12 +00:00
dumbmoron
38ce64b310 web: fix type errors 2024-09-03 10:20:29 +00:00
wukko
cef90219e0 web: partially pre-render web for page metadata 2024-09-03 14:39:52 +06:00
dumbmoron
5b42757896 web: remove background override from global css 2024-09-03 07:58:52 +00:00
wukko
30c4c1ad20 web/device: add default values & types 2024-09-03 13:52:41 +06:00
wukko
645542c910 api/bluesky: catch video errors & prevent loading videos not from bsky 2024-09-03 13:24:08 +06:00
dumbmoron
4b4fce326f test: update match() arguments to new format 2024-09-02 14:18:11 +00:00
dumbmoron
2deb8aa53b web: add fade-in animation 2024-09-02 14:16:21 +00:00
wukko
8cee4e58c5 api/match: accept object as single argument
hi im what i do
2024-09-02 08:27:31 +06:00
wukko
93f2a6b226 api/bluesky: add support for saving images
one or multiple, everything works
2024-09-01 16:37:24 +06:00
wukko
d9925f2233 api/core: move friendly name map to server info 2024-09-01 16:02:06 +06:00
wukko
805e5d42c0 api/config: apply friendly name in a map
filter doesn't allow changing the value
2024-09-01 15:56:51 +06:00
wukko
c71ed59660 api/url: return friendly name in unsupported link error 2024-09-01 15:51:35 +06:00
wukko
97fb6e60a2 api/service-alias: refactor 2024-09-01 15:35:49 +06:00
wukko
740a75851e api: add support for service name aliases
currently only used for bluesky
2024-09-01 15:28:29 +06:00
wukko
57050fb742 api/match-action: fix mute tag assignment for default filename
it's also no longer applied to photos and gifs LMFAOOO
2024-09-01 14:59:35 +06:00
wukko
67073b274d api/tests: add bluesky tests 2024-09-01 14:41:41 +06:00
wukko
b727a56d67 api/bluesky: catch fetch errors (oops) 2024-09-01 14:39:28 +06:00
wukko
6c9601690b api: add support for bluesky videos & clean up service patterns 2024-09-01 14:34:44 +06:00
wukko
4478a963c5 api/config: add cobalt user agent 2024-09-01 14:15:16 +06:00
wukko
f3521da9c1 api/stream/remux: convert audio to aac to increase compatibility
only applies to hls exceptions
2024-09-01 14:09:58 +06:00
wukko
ccdcd4cb09 api/url: add missing break to dailymotion case 2024-09-01 12:00:48 +06:00
dumbmoron
7227a4ad6e web/layout: enable ssr 2024-08-31 17:46:52 +00:00
dumbmoron
f038e6a379 web: remove default title 2024-08-31 17:46:47 +00:00
dumbmoron
51c140fbfa web: make everything ssr-compatible 2024-08-31 17:46:10 +00:00
wukko
8e9e8ab63f web/privacy: mark tunnelling feature as beta 2024-08-31 15:32:30 +06:00
wukko
ac76f8e32b web/instances: mark community instances feature as beta 2024-08-31 15:32:16 +06:00
wukko
a46972c9a5 web/SettingsCategory: add ability to mark features as beta 2024-08-31 15:32:02 +06:00
wukko
fa941e9d82 web/i18n/settings: update privacy setting descriptions
made them easier to read
2024-08-31 15:20:02 +06:00
wukko
f464d87585 api/tiktok: add support for proxying images in a picker 2024-08-31 15:01:09 +06:00
wukko
0852f5dc09 api/snapchat: always proxy picker thumbs & support proxying everything 2024-08-31 14:52:12 +06:00
wukko
892b875e3f api/reddit: add a filename to returned gifs 2024-08-31 14:27:46 +06:00
wukko
10717c69f6 api/twitter: support proxying videos & images in a picker 2024-08-31 14:23:18 +06:00
wukko
00da2a9339 api/twitter: return a filename for images 2024-08-31 14:19:01 +06:00
wukko
d026eb75a5 api/instagram: add support for proxying everything in a picker 2024-08-31 14:10:03 +06:00
wukko
4476ae0672 api/pinterest: add missing filenames to images & gifs 2024-08-31 14:09:34 +06:00
wukko
d0d0f16c5f web/privacy: add support for always proxying files 2024-08-31 14:08:48 +06:00
wukko
a9e65b0da0 api: add an option to always proxy files 2024-08-31 14:07:37 +06:00
dumbmoron
2f63f6bab7 api/proxy: add support for proxying range requests 2024-08-31 07:15:20 +00:00
dumbmoron
305d0429f1 web/donate: make page scrollable on sides 2024-08-30 16:48:23 +00:00
wukko
744842cc3d web/CustomInstanceInput: fix colors & overflow in firefox and safari 2024-08-30 22:01:14 +06:00
dumbmoron
3d631b6c30 DonationOption: appropriately format amounts 2024-08-30 15:40:06 +00:00
wukko
d8bacbeeef api/tests/twitter: allow retweeted video tests to fail 2024-08-30 21:35:29 +06:00
dumbmoron
57a7090eb2 web/DonateOptionsCard: add more preset options 2024-08-30 15:33:18 +00:00
wukko
1debf3e639 web/settings/download: fix padding for filename preview 2024-08-30 21:31:21 +06:00
wukko
063f5d1806 web/CustomInstanceInput: proper style 2024-08-30 21:31:02 +06:00
wukko
ebb5deb43c web/safety-warning: remove misplaced comparison 2024-08-30 17:25:31 +06:00
wukko
b878d5f4f9 web/settings/appearance: slight import refactor 2024-08-30 17:18:04 +06:00
wukko
1850264da7 web/LanguageDropdown: refactor 2024-08-30 17:17:43 +06:00
wukko
b3954b9209 web/ResetSettingsButton: move to settings subdir & reduce timeout to 2s 2024-08-30 17:17:29 +06:00
wukko
3cdd615734 web/i18n/settings: more general debug description 2024-08-30 17:16:19 +06:00
wukko
cf42b1b2ef web/i18n/error: shorter timed out message 2024-08-30 17:16:04 +06:00
wukko
33d6b5bd81 web: base custom instance functionality
also:
- renamed processing tab in settings to "instances"
- improved override description
- prefer custom over override (and grey out the option)
- dedicated lib for all api safety warnings
- left aligned small popup with smaller icon
- ability to grey out settings category & toggle
2024-08-30 17:15:05 +06:00
wukko
70c1a85766 web/DonateOptionsCard: fix input click area 2024-08-30 15:11:28 +06:00
wukko
524235907d web/DonateOptionsCard: remove stepper in firefox 2024-08-30 15:03:04 +06:00
dumbmoron
5cbc91cba9 tests: mark "twitter voice" test as canFail 2024-08-30 08:40:04 +00:00
dumbmoron
97266a46fa ci: shuffle ciphers before running service tests 2024-08-30 08:30:34 +00:00
wukko
7f8204bc0c api/core: handle all express errors, not just path parsing (wtf) 2024-08-30 14:25:46 +06:00
wukko
329b068038 api/schema: don't decode the url
this is from the dinosaur era of cobalt and hasn't been used since we moved to POST requests
2024-08-30 14:09:18 +06:00
wukko
1fe419784d web/DonateOptionsCard: recolor the focus border of input container
also removed additional 0.5px that got partially cropped
2024-08-29 20:58:49 +06:00
wukko
f0ce0ccef7 web/server-info: refresh server info cache if endpoint changes 2024-08-29 18:10:46 +06:00
wukko
f7da62e817 web/Omnibox: increase max length to 512 characters 2024-08-29 13:22:52 +06:00
wukko
5dc0cf1772 web/processing: hide override section when DEFAULT_API isn't present 2024-08-29 13:20:17 +06:00
wukko
e59b7fd375 web/FileReceiver: reduce padding 2024-08-28 15:06:00 +06:00
wukko
3295afdaae api/create-filename: don't add dub name when it doesn't exist 2024-08-28 00:35:53 +06:00
wukko
2068bba4ee web/SupportedServices: make the button more minimal 2024-08-27 19:40:30 +06:00
wukko
74a2758413 web/i18n/save: update disclaimer & translate new strings to russian 2024-08-26 23:52:13 +06:00
wukko
42410f7b20 web/save: add supported services popover 2024-08-26 23:43:39 +06:00
wukko
7524d202f7 web/session: merge cached state into main lib 2024-08-26 23:38:24 +06:00
wukko
59308ed09f web/about: add the link type of alternative donation method 2024-08-25 17:10:17 +06:00
wukko
1c258ab0ae web/env: add more donation methods 2024-08-25 17:09:31 +06:00
wukko
83f1744508 web/about: move bottom margin to section 2024-08-25 16:53:51 +06:00
wukko
685f8cb65e web/sidebar: fix auto scroll & clean up 2024-08-25 16:43:24 +06:00
wukko
a8330b25fa web/donate: reduce bottom margin 2024-08-25 16:41:10 +06:00
wukko
481697ea12 web/donate: add crypto donation options 2024-08-25 16:38:13 +06:00
wukko
1147244e46 web/env: updated the list of crypto addresses 2024-08-25 16:34:27 +06:00
wukko
b8fc3aeb4c web/CopyIcon: added regular copy icon 2024-08-25 16:34:08 +06:00
wukko
a589bf7e54 api/youtube: fix audio dub track detection 2024-08-25 14:48:46 +06:00
wukko
6dcd951e21 api/create-filename: refactor 2024-08-25 14:48:28 +06:00
wukko
6aa39dd1d1 web/i18n/error: add token expiration youtube error & update login error 2024-08-24 18:34:38 +06:00
wukko
0bbf822d70 api/youtube: catch token expiration error 2024-08-24 18:34:02 +06:00
wukko
856004366e api/match: fix typo in fetch.critical code 2024-08-24 17:28:43 +06:00
wukko
7478a373fc api & web: add service context to api.fetch.critical error 2024-08-24 17:24:51 +06:00
wukko
0b7af10ab0 web/i18n/error: update api.link.invalid 2024-08-24 17:21:44 +06:00
wukko
e80a110264 web/i18n: add all strings for api errors 2024-08-24 17:13:35 +06:00
wukko
7ac0726f37 api: move error context to matcher 2024-08-24 16:56:07 +06:00
wukko
37efa035a2 api/twitter: update no tweet error code 2024-08-24 16:31:19 +06:00
wukko
0d58fad580 web/session: update session fetch error code 2024-08-24 16:16:12 +06:00
wukko
c0284fac13 web/translations: handle error contexts 2024-08-24 16:15:48 +06:00
wukko
7041d61d80 api/core: fix link parsing error handling 2024-08-24 16:13:42 +06:00
wukko
cc05833c6a web/SettingsNavBar: fix text line height 2024-08-23 23:17:39 +06:00
wukko
09a6b5179e web/SidebarTab: fix selection animation in webkit 2024-08-23 21:07:18 +06:00
wukko
2479900bb9 web/donate: adjust donate card gradient colors for dark theme 2024-08-23 20:50:38 +06:00
wukko
1f25b3d793 web/DonateShareCard: remove gradient on mobile 2024-08-23 20:50:17 +06:00
wukko
7daceea049 web/DonateCardContainer: reduce padding on mobile (again) 2024-08-23 20:25:03 +06:00
wukko
8200541b21 web/DonateBanner: reduce padding on mobile 2024-08-23 20:24:47 +06:00
wukko
ba3602aabb web/DonateBanner: slightly reduce amount of hearts 2024-08-23 20:13:04 +06:00
wukko
0d8065ac70 web/DonateBanner: reduce padding 2024-08-23 20:05:21 +06:00
wukko
8df70cc00b web: update page title style
prioritize the page title, not "cobalt"
2024-08-23 20:02:17 +06:00
wukko
1e5bc67e92 web/DonateCardContainer: reduce padding 2024-08-23 19:55:10 +06:00
wukko
24f2329e9d web/settings: slightly update navigation style on mobile 2024-08-23 19:44:10 +06:00
wukko
70264f3691 api/core: update server info
- cache server info as string
- serve a list of services & duration limit in server info
2024-08-23 00:33:52 +06:00
wukko
7a557a97c3 api: move service disabling to DISABLED_SERVICES env 2024-08-23 00:16:26 +06:00
wukko
ee375a27cd api/schema: enable tiktok full audio by default 2024-08-22 23:17:49 +06:00
wukko
b6d2175d4b web/settings/defaults: enable full tiktok audio by default 2024-08-22 23:15:05 +06:00
dumbmoron
d7d707e666 web/DialogHolder: fix dialog animation jumping on new safari versions
WebKit/WebKit@ce08f32453 breaks this animation on newer versions
of safari. why? i guess we'll never know
2024-08-22 17:09:58 +00:00
wukko
a21c9e7632 api/core/api: clean up imports 2024-08-22 23:04:11 +06:00
wukko
c7a08844e3 web/i18n/settings: update audio format & bitrate descriptions 2024-08-22 22:57:51 +06:00
wukko
102dec4a84 api/match-action: clean up audio action 2024-08-22 22:45:26 +06:00
wukko
b3d846a1e3 web/i18n/settings: update audio bitrate description 2024-08-22 20:50:45 +06:00
wukko
ff9efdc471 api & web: update default audio bitrate to be 128kbps 2024-08-22 20:50:21 +06:00
wukko
42ff874c95 api/schema: add 8bkps audio bitrate option 2024-08-22 20:06:54 +06:00
wukko
49184a235d web/save: add support for audio bitrate functionality 2024-08-22 19:38:39 +06:00
wukko
91fd26e880 api: add audio bitrate functionality 2024-08-22 19:35:17 +06:00
wukko
fb5d68a830 api/instagram: add file name for proxied image thumbs 2024-08-22 18:02:53 +06:00
wukko
76fa1b2b87 api/twitter: add proper file name for proxied thumb 2024-08-22 17:40:44 +06:00
wukko
facf7741ce api/stream: standardize stream types & clean up related functions 2024-08-22 17:37:31 +06:00
wukko
1064be6a7a api/twitter: proxy thumbnails 2024-08-22 13:38:16 +06:00
wukko
07dc176024 api/stream/types: fix cross origin resource policy for proxy 2024-08-22 13:38:04 +06:00
wukko
ed4a5889ab web/DownloadButton: fix download audio i18n 2024-08-22 13:05:17 +06:00
wukko
3057f9cffb web/DownloadButton: replace rem sizes with pixels 2024-08-20 22:34:47 +06:00
wukko
281ae25d4a api/youtube: prefer higher quality even if premuxed video is available 2024-08-20 22:27:03 +06:00
wukko
555625878e api/schema: update twitterGif & videoQuality defaults 2024-08-20 22:04:17 +06:00
wukko
cd9be54023 web/settings: update twitterGif & videoQuality defaults 2024-08-20 22:02:06 +06:00
wukko
98be6f017c web/i18n/settings: update twitter gif description 2024-08-20 22:01:33 +06:00
wukko
265ab77948 web/i18n: update string name formatting 2024-08-20 21:17:33 +06:00
wukko
05abf9ad3e api: update error codes in services, add more error codes where needed 2024-08-20 21:10:37 +06:00
wukko
c698d272a1 api/jwt: return relative expiration date to accommodate offset clocks 2024-08-19 22:25:21 +06:00
wukko
1f3509db07 api: update error codes in api core functions 2024-08-19 21:51:45 +06:00
dumbmoron
a4d57f175e web/libav: try to guess type from filename if browser fails 2024-08-17 14:07:10 +00:00
wukko
911f283b78 web/html: prevent chrome & darkreader from messing up the dark theme 2024-08-17 19:52:39 +06:00
dumbmoron
e678bd25fc web/lib/libav: clean up extension handling 2024-08-17 13:45:58 +00:00
dumbmoron
9d7512d6e5 web/remux: lower percentage bound 2024-08-17 13:45:18 +00:00
dumbmoron
7d10ab765e web/remux: override mp3 extension from mime type 2024-08-17 13:26:43 +00:00
dumbmoron
d446dfd87e web/remux: correctly unset processing state 2024-08-17 13:26:43 +00:00
wukko
0e461d4ebe web/session: add a delta to prevent token expiring on its way to api 2024-08-17 18:05:00 +06:00
wukko
9592e59f76 api/jwt: fix timestamp to match the spec 2024-08-17 17:58:40 +06:00
dumbmoron
580ca042f3 web/remux: display error if probe fails 2024-08-17 10:15:05 +00:00
dumbmoron
3bef12ff33 web/remux: copy all streams when remuxing 2024-08-17 10:07:55 +00:00
wukko
974b98f0ac api/core: fix & clean up auth middleware 2024-08-17 00:59:59 +06:00
wukko
30c51b9fe8 api/core: rate limit by token if it's present 2024-08-17 00:55:26 +06:00
wukko
c54294601b api/core: limit authorization header length 2024-08-17 00:13:26 +06:00
wukko
a49a87544c web/session: don't expect a trailing slash in DEFAULT_URL 2024-08-16 23:52:40 +06:00
wukko
3336210e93 web/state/session: clean up 2024-08-16 23:48:50 +06:00
wukko
51bd2f72fd api/core: fix typo 2024-08-16 23:41:20 +06:00
wukko
4857030933 web/api: jwt session token, clean up, move related modules to own dir 2024-08-16 23:36:56 +06:00
wukko
16acf62886 api/security: jwt session token 2024-08-16 23:28:03 +06:00
wukko
33c2fee847 web/remux: add page title 2024-08-16 22:52:33 +06:00
wukko
ac9568a422 web/remux: fix ghost click areas when processing 2024-08-16 02:13:26 +06:00
wukko
275c982c80 web/Sidebar: hide unfinished tabs 2024-08-16 01:55:25 +06:00
wukko
b9fabdc327 web/Omnibox: clean up input link icon code 2024-08-16 01:44:13 +06:00
wukko
478dd6e515 web/Omnibox: show a spinner when loading
replaces the link icon with a spinner when loading the turnstile checks or processing the link
2024-08-16 01:37:05 +06:00
wukko
8b9e3f58f4 web/i18n/error: add api authentication error 2024-08-16 00:24:21 +06:00
wukko
4283774c6c api: add support for cloudflare turnstile 2024-08-16 00:10:17 +06:00
wukko
384c6deced web: add support for cloudflare turnstile 2024-08-16 00:08:57 +06:00
wukko
c1813aa33f api/stream: rename bridge mode to proxy 2024-08-15 19:58:40 +06:00
wukko
9d577f23b1 web/FileReceiver: fix focus ring 2024-08-15 12:58:26 +06:00
wukko
5ce4ef8366 web/FileReceiver: reduce padding, fix scaling 2024-08-14 13:07:34 +06:00
wukko
70a8c53cba web/Sidebar: add blank convert & shrink tabs 2024-08-14 01:43:04 +06:00
dumbmoron
8af6761951 web/remux: transition into processing state before probe 2024-08-13 15:55:47 +00:00
dumbmoron
1493762ce9 web/remux: add speed to progress info 2024-08-13 15:55:30 +00:00
dumbmoron
4c2acc595e web/remux: add basic progress example 2024-08-13 15:34:53 +00:00
dumbmoron
f93d84c457 web/libav: add ffprobe wrapper 2024-08-13 15:34:53 +00:00
dumbmoron
4636f7b0d4 web/libav/wrapper: make sure libav is initialized only once 2024-08-13 15:34:53 +00:00
dumbmoron
72545ffb5d web/remux: init libav on page load, basic progress event handling 2024-08-13 15:34:53 +00:00
dumbmoron
28600e7e4c web/libav: emit progress events 2024-08-13 15:34:53 +00:00
wukko
f661e839b1 web/i18n/remux: less yapping in description 2024-08-13 16:56:46 +06:00
wukko
c8904fd939 web/FileReceiver: reduce icon size, change icon when dragged over 2024-08-13 16:48:53 +06:00
wukko
b9958a8102 web/FileReceiver: fix meowbalt z index 2024-08-13 16:16:50 +06:00
wukko
0d41fe6fa3 web/FileReceiver: animated svg dashed stroke, padding improvements 2024-08-13 16:15:00 +06:00
wukko
70b300bd71 web/remux: add processing state, clean file, prevent render loop 2024-08-13 15:22:24 +06:00
wukko
1fd2b72075 web/remux: rename DropReceiver component 2024-08-13 14:17:58 +06:00
wukko
af428bc964 web: stylize the file receiver, move text to i18n, update remux page 2024-08-13 14:17:10 +06:00
wukko
09deb5c7b6 web/Sidebar: move settings tab to bottom & move updates tab lower 2024-08-13 12:35:18 +06:00
wukko
6cc0871d99 web: add static headers file for multithreading on cloudflare pages 2024-08-13 12:06:09 +06:00
dumbmoron
e79f466c5f web/libav: shrink buffer _after_ ffmpeg is done running 2024-08-13 00:23:13 +00:00
dumbmoron
e084092f37 web/libav: remove stray curly brace 2024-08-12 20:39:36 +00:00
dumbmoron
bc272b910e web/libav: fix import double slash, use unlinkreadaheadfile 2024-08-12 20:36:24 +00:00
dumbmoron
5fb9b1c809 web: bump libav.js version 2024-08-12 20:34:57 +00:00
dumbmoron
83f46864b2 web/libav: properly instantiate libav property 2024-08-12 20:03:30 +00:00
dumbmoron
038c29dc8d web/libav: move types to dedicated file 2024-08-12 20:03:07 +00:00
wukko
b2288ed037 web: remove ffmpeg worker workaround 2024-08-13 00:23:20 +06:00
wukko
ed722e77ea web: remove ffmpeg wasm dependencies 2024-08-13 00:20:01 +06:00
wukko
55ab3c36b1 web/lib: remove ffmpeg 2024-08-13 00:18:45 +06:00
dumbmoron
940826697c web/libav: preallocate memory for output when remuxing 2024-08-12 18:07:04 +00:00
dumbmoron
2198a696ce web/libav: make it work & clean up 2024-08-12 17:06:45 +00:00
dumbmoron
75ef4604d8 web/vite: expose libav.js files directly 2024-08-12 16:47:11 +00:00
wukko
6a04312781 web: update package 2024-08-12 22:30:09 +06:00
wukko
57054c24b2 web: draft libav functionality 2024-08-12 22:28:38 +06:00
wukko
778eb51502 web/ffmpeg: don't return an empty blob 2024-08-11 19:13:04 +06:00
wukko
3fd05891e6 web/remux: move drop area and open file button into own components 2024-08-11 18:30:42 +06:00
wukko
b33bd39484 web/ffmpeg: accept and return blob, proper types & extensions, clean up 2024-08-11 18:24:29 +06:00
wukko
f87f6fa9c9 web/remux: accept files on drag, update ffmpeg function call 2024-08-11 13:05:15 +06:00
wukko
1113ddd9c5 web/ffmpeg: universal render function for all needs 2024-08-11 13:04:40 +06:00
dumbmoron
7044100aed web: fix build for remux and remove unused import 2024-08-10 11:45:16 +00:00
wukko
3c2dd93841 web/SidebarTab: fix scroll to first page of tabs 2024-08-10 17:43:53 +06:00
wukko
41a002929e web: barebones core for ffmpeg & remux page 2024-08-10 17:21:39 +06:00
wukko
ebd6cc801b web/Sidebar: remove incorrect aria-orientation 2024-08-09 21:07:38 +06:00
wukko
5e0824022c web/DonateShareCard: add alt text for qr code 2024-08-09 21:04:56 +06:00
wukko
b71d51de21 web: move svg icons from lib to components 2024-08-09 16:35:00 +06:00
wukko
6bda6dab03 web/DonateShareCard: hide twitter button in russia 2024-08-09 16:06:33 +06:00
wukko
d1a2d768d9 web/DonateShareCard: expand the card on QR press & better scaling 2024-08-09 16:06:19 +06:00
wukko
f81f155eb0 web/DonateShareCard: clean up imports 2024-08-09 14:52:48 +06:00
wukko
ffea8e6f2e web/DonateShareCard: hide share button if user agent doesn't support it 2024-08-09 14:52:22 +06:00
wukko
e7386234bc web/DonateShareCard: fix unrelated buttons triggering the copy anim 2024-08-09 14:51:25 +06:00
wukko
e4ec468f60 web/DonateShareCard: change action button id and class names
some adblock lists block everything named "share-button(s)"
2024-08-09 14:47:54 +06:00
wukko
077471d799 web/DonateShareCard: localize strings 2024-08-09 14:40:52 +06:00
wukko
536d9c9742 web/i18n: move call to action button strings to own file 2024-08-09 14:40:30 +06:00
wukko
21ef35ea20 web/donate: add a share card with qr and buttons
also:
- fixed more scaling quirks
- fixed thick icons
- fixed icon padding
2024-08-09 14:35:55 +06:00
wukko
e45aa2bdf6 web/CopyIcon: replace copy icon with a link icon 2024-08-09 14:35:06 +06:00
dumbmoron
2e9721d611 repo: set up api-client package 2024-08-09 07:55:21 +00:00
wukko
937d12ddff web/donate: bring up repeated card css to container
also fixed some scaling quirks on mobile
2024-08-09 13:13:48 +06:00
wukko
209833c8ea web/DonateOptionsCard: update subtitle color 2024-08-09 12:27:49 +06:00
wukko
b8cd6eb1e7 web/Sidebar: update tab holder aria role 2024-08-09 12:05:59 +06:00
wukko
608824f862 web/DonateBanner: focus the title first on the page 2024-08-09 12:05:15 +06:00
wukko
c94266a127 web/DonateOptionsCard: better accessibility 2024-08-09 12:04:43 +06:00
wukko
d4e91f2a1c web/DonateOptionsCard: fix button active color 2024-08-09 11:48:04 +06:00
wukko
cc5835a546 web/DonateOptionsCard: adjust padding, fix width, mask options row 2024-08-09 11:46:56 +06:00
wukko
98ac05ad86 web/donate: reduce text padding on mobile 2024-08-09 11:35:05 +06:00
wukko
80c26f712c web/DonateBanner: better scaling 2024-08-09 11:31:57 +06:00
wukko
abeacd7534 web/DonateOptionsCard: move all strings to i18n, mobile scaling 2024-08-09 11:31:44 +06:00
wukko
e727e3a95b api/processing: slightly reformat code related to zod 2024-08-08 23:43:04 +06:00
dumbmoron
66d70ffc44 test: update match() call format 2024-08-08 16:35:19 +00:00
dumbmoron
f32f624916 api: use zod for request schema validation 2024-08-08 16:34:54 +00:00
dumbmoron
b510cbf9e0 web/donate: add dollar sign before custom input 2024-08-06 18:30:06 +00:00
dumbmoron
c90a01daf9 web/donate: update logic for sending custom amounts 2024-08-06 18:30:06 +00:00
dumbmoron
d67ed89c38 web/donate: refactor, implement logic for choosing donation periodicity 2024-08-06 18:30:06 +00:00
dumbmoron
1077797aae web/donate: add minimum/maximum amounts to donation amount 2024-08-06 18:30:06 +00:00
wukko
03152375ec api/snapchat: fix the reverse strict not equals operator
oh my god
2024-08-06 21:35:39 +06:00
wukko
4505d6cfe1 web/api: merge picker types 2024-08-06 21:32:17 +06:00
wukko
9ff27391d0 api/snapchat: don't return thumb for photos in a picker
prevents saving low quality images with a long press
2024-08-06 21:31:43 +06:00
wukko
72a21b203e api/twitter: don't return thumb for photos in a picker
it's the same image anyway, this just makes the response smaller
2024-08-06 21:31:15 +06:00
wukko
95f5fd978f api: merge two picker types into one 2024-08-06 21:30:18 +06:00
wukko
ac6d68ec45 web/api: remove deprecated statuses, update error type, time out request
also updated some error codes
2024-08-06 20:50:20 +06:00
wukko
f96c1cd13b api: remove deprecated statuses & clean up related code 2024-08-06 20:45:04 +06:00
dumbmoron
5948cab4fb web: move iPadOS workaround to global level 2024-08-06 14:19:01 +00:00
dumbmoron
3b90f5ee17 web/Omnibox: workaround for link area being unusable on iPadOS 15 2024-08-06 14:02:21 +00:00
dumbmoron
075b2799e3 web/DonateBanner: fix top scroll caused by banner hearts overflowing
wtf
2024-08-06 13:24:11 +00:00
dumbmoron
0b602b9164 web/DonateBanner: disable pointer events for background animation 2024-08-06 12:42:17 +00:00
wukko
83cd51f4db web/DonateOptionsCard: fix width 2024-08-06 16:53:40 +06:00
wukko
a25e0bfb04 web/DonateOptionsCard: fix prefilled stripe amount 2024-08-06 16:48:39 +06:00
wukko
14d0577895 web/DonateOptionsCard: hide webkit stepper button 2024-08-06 16:46:32 +06:00
wukko
eccf75b7b7 web/donate: redesign the donation options card 2024-08-06 16:45:25 +06:00
wukko
5d58502fd8 web/DonateBanner: fix meowbalt overlapping the update notification 2024-08-06 14:57:10 +06:00
wukko
c98b08fc7f web/DonateBanner: load imput logo as component 2024-08-06 14:54:31 +06:00
wukko
e074dd5b89 web/layout: more global styling for long-text-noto 2024-08-06 14:50:12 +06:00
wukko
bf73f512e2 web/donate: move donation options card to own component
- moved reused variables to parent
- added body text
2024-08-06 14:48:06 +06:00
wukko
87b76ec1e2 web/layout: add global long-text-noto style class 2024-08-06 14:29:15 +06:00
wukko
894c1ada24 web/DonateBanner: make subtitle easier to read 2024-08-06 14:28:32 +06:00
wukko
250269bc09 web/DonateBanner: move to donate components folder 2024-08-06 14:27:56 +06:00
wukko
c8a0f74e62 web/DonateBanner: dark theme & scaling support 2024-08-06 14:09:16 +06:00
wukko
9dad15b48c web/DonateBanner: fix up colors & sizes to match mockup 2024-08-06 13:24:40 +06:00
dumbmoron
640898e022 web/donate: move header to component, initial donation options 2024-08-04 16:11:26 +00:00
dumbmoron
e72efae24d web/donate: initial re-redesign 2024-08-04 16:11:26 +00:00
dumbmoron
d431e9e9a2 web: add fast meowbalt 2024-08-04 16:11:26 +00:00
wukko
6267d1d58f web/version: use workspace version-info package 2024-08-04 22:01:36 +06:00
wukko
bfbf653463 web/package: update version to 10.0.0 2024-08-04 00:52:27 +06:00
wukko
61f8a61986 web/i18n/settings: point out that processing override desc isn't final 2024-08-04 00:47:09 +06:00
wukko
aba444ec8b web: updated api endpoint & params, default instance override
- dialogs can be undismissable now (impossible to click away by pressing the bg behind it)
- added security warning about api override
- moved default api url to env
- added new processing settings page
2024-08-04 00:43:24 +06:00
dumbmoron
168c1bdbbb api/test: update tests to use new request format 2024-08-03 17:33:56 +00:00
dumbmoron
94c2545ca6 api/serverInfo: make cors into boolean 2024-08-03 17:11:18 +00:00
wukko
0a28b4091f api/youtube: small indentation fix 2024-08-03 23:08:59 +06:00
wukko
5ea71ee58e api: update post parameters
they're now way easier to read
2024-08-03 23:06:32 +06:00
dumbmoron
d419fd35b0 ci: add build action for develop tag 2024-08-03 16:48:57 +00:00
dumbmoron
a7ab4a9706 version-info: don't throw on import if git does not exist 2024-08-03 16:48:57 +00:00
dumbmoron
985f6e385d docker: update dockerfile to use pnpm features 2024-08-03 16:48:57 +00:00
dumbmoron
c751837ed8 api/package: change name to @imput/cobalt-api 2024-08-03 16:48:57 +00:00
wukko
f227abf6cb docs/examples/compose: remove version top-level element 2024-08-03 22:09:35 +06:00
wukko
c57dce6806 docs/examples/compose: remove web example 2024-08-03 22:07:14 +06:00
wukko
e58bca0cdd api: remove alias mapping for deprecated env variables 2024-08-03 22:05:50 +06:00
wukko
edbea16b91 ci/test.sh: update api endpoint 2024-08-03 21:51:46 +06:00
wukko
3bd1a00855 api: renovate endpoints
no more redundant "/api" path
2024-08-03 21:51:05 +06:00
wukko
40425ad3bf api: use version-info package & clean up start message 2024-08-03 21:34:02 +06:00
wukko
0d20ffc004 packages: add version-info package 2024-08-03 21:27:14 +06:00
wukko
bef9b5b172 snapchat: add support for android short links 2024-08-03 16:50:57 +06:00
wukko
417a21ea91 ci: fix service config retrieval 2024-08-03 16:36:01 +06:00
wukko
0a411196e9 api/config: clean up (better formatting) 2024-08-03 16:33:36 +06:00
wukko
332eae16b2 api: convert service config to JS and remove it from main config 2024-08-03 16:27:20 +06:00
dumbmoron
3a2504ae87 ci: fix invalid node version for web 2024-08-03 09:52:46 +00:00
dumbmoron
18b3daf90f web/package: lower minimum node version, replace npm with pnpm 2024-08-03 09:50:42 +00:00
dumbmoron
786fd0555e ci: use node v20 for web build, clean up 2024-08-03 09:50:24 +00:00
dumbmoron
2beacce70d ci: use pnpm in tests 2024-08-03 09:32:42 +00:00
dumbmoron
e50b4b0328 repo: set up package.json in root 2024-08-03 09:31:59 +00:00
dumbmoron
350e1d4d4f ci: update tests for new cobalt 2024-08-03 09:29:33 +00:00
dumbmoron
6d817f149e api/load-from-fs: refactor loadFile, use in loadJSON 2024-08-03 09:29:33 +00:00
dumbmoron
0e0ad7cb0e api/load-from-fs: always load files from root of api folder 2024-08-03 09:29:33 +00:00
wukko
8e7b63ade6 api/match: fix audio format variable typo 2024-08-03 15:05:00 +06:00
wukko
0ffea2d886 api/match: pass action parameters as object 2024-08-03 15:02:59 +06:00
dumbmoron
559e8448e5 repo: add static build to gitignore 2024-08-03 08:59:57 +00:00
wukko
dd831e13e8 api: flatten code directories, better filenames, remove old files 2024-08-03 14:47:13 +06:00
dumbmoron
5ce208f1a5 ci: run tests on all branches 2024-08-03 10:38:57 +02:00
wukko
aff22e8560 api: remove localization, renovate error response 2024-08-03 13:51:09 +06:00
dumbmoron
3fdf266ad0 youtube: periodically refresh innertube player 2024-08-03 12:46:01 +06:00
wukko
dd30973601 package: update version to 10.0.0 2024-08-02 22:35:56 +06:00
wukko
f66ae63d57 api/core: remove favicon 2024-08-02 22:35:49 +06:00
wukko
012fadd2f0 api: remove API_NAME env variable & from server info 2024-08-02 21:33:59 +06:00
wukko
2575b0c145 api: remove web mode & variables 2024-08-02 21:32:00 +06:00
wukko
eede972ace api: remove old frontend files 2024-08-02 21:23:56 +06:00
wukko
ec98605336 git: update general gitignore & web gitignore 2024-08-02 21:16:04 +06:00
wukko
4f877f199b web: add to pnpm workspace and move lock to pnpm 2024-08-02 21:03:25 +06:00
wukko
c1179e2c9b merge: svelte branch into 10 2024-08-02 20:57:29 +06:00
wukko
65e095b178 repo: add pnpm workspace config & lock 2024-08-02 20:45:32 +06:00
wukko
213d259b09 repo: remove package-lock 2024-08-02 20:43:37 +06:00
wukko
a7087336ca repo: move api src from root to own subdir 2024-08-02 20:41:52 +06:00
wukko
008163073d web/updates: merge skeleton & entry components 2024-07-31 19:28:48 +06:00
wukko
2c4ba96d57 web/TransferSettings: pretty formatting 2024-07-31 14:59:53 +06:00
wukko
e7587a2ec6 web/TransferSettings: friendlier error messages 2024-07-31 14:57:34 +06:00
wukko
585ebd9cb4 web/settings/advanced: hide reset settings button if there are no settings 2024-07-31 13:53:34 +06:00
dumbmoron
d1930c1dbc web/advanced: check if imported settings are valid 2024-07-30 17:15:38 +00:00
dumbmoron
3d34e09e1c web: don't display pointer cursor for disabled buttons 2024-07-30 16:37:44 +00:00
dumbmoron
a6a0e91674 web/TransferSettings: don't offer export if there is nothing to export 2024-07-30 14:08:49 +00:00
wukko
96df9d55b8 web/updates: fix duplicate css 2024-07-29 14:46:10 +06:00
wukko
5fc893a273 web/updates: add alt text to navigation buttons 2024-07-29 14:32:15 +06:00
wukko
8b866ddf6f web/SettingsNavTab: reduce padding on desktop 2024-07-29 13:11:29 +06:00
wukko
a4e0e21a97 web/Omnibox: accept keyboard shortcuts only when focused 2024-07-28 23:36:38 +06:00
wukko
48d24ee1ea web/SavingDialog: show that link was copied, better accessibility 2024-07-28 23:29:32 +06:00
wukko
3aeebcc911 web/SavingDialog: don't render body text parent if there's no text 2024-07-28 19:20:32 +06:00
wukko
97e7763503 web/download: show explanation when getting blocked by browser 2024-07-28 19:15:22 +06:00
wukko
5c780a2d2e web: added saving method preference, made downloading resilient 2024-07-28 18:59:58 +06:00
wukko
87adffaf02 web/ResetSettingsButton: add a timeout to reset button in dialog 2024-07-28 14:51:02 +06:00
wukko
f34340a06d web/TransferSettings: add a timeout to import button in dialog 2024-07-28 14:50:59 +06:00
wukko
11d3d71937 web/dialogs: move buttons to own component & add optional timeout 2024-07-28 14:49:12 +06:00
wukko
a2ead8a813 web/i18n/dialog: add more info to import warning 2024-07-28 14:08:27 +06:00
wukko
cce3ce4cfa web/download: show saving dialog if new tab got blocked 2024-07-28 13:49:23 +06:00
dumbmoron
299d1867a2 web/TransferSettings: only export storedSettings instead of all settings 2024-07-28 07:46:15 +00:00
wukko
71204054c7 web/settings/defaults: disable downloadPopup by default 2024-07-28 13:33:09 +06:00
wukko
82091db154 web/download: open saving dialog if user action expired 2024-07-28 13:32:21 +06:00
wukko
4210b17d89 web/TransferSettings: show a safety warning before importing 2024-07-28 13:20:22 +06:00
wukko
5bb5c6dc3c web/SmallDialog: fix line breaking in body text 2024-07-28 13:15:28 +06:00
wukko
194ff90d3d web/layout: fix elevated colored button hover & active states 2024-07-28 13:14:55 +06:00
wukko
2fa1ad8f12 web/SavingDialog: hide buttons when actions are not supported 2024-07-28 12:49:13 +06:00
wukko
32743360be web/Omnibox: fix dimmed input placeholder in firefox 2024-07-27 19:27:58 +06:00
wukko
1ded7698ff web/Omnibox: fix input area dimming in safari when processing 2024-07-27 19:26:39 +06:00
wukko
6072fbac5c web/DialogHolder: fix bottom margin on mobile during the close animation 2024-07-27 19:26:07 +06:00
wukko
59f5560802 web/dialogs: move backdrop close handler to container 2024-07-27 15:30:00 +06:00
wukko
b8eb708748 web/DialogHolder: fix typescript error, add a note 2024-07-27 15:28:02 +06:00
wukko
26eaac5742 web/ActionButton: clean up 2024-07-27 15:07:38 +06:00
wukko
a9f9a3e342 web/dialogs: add saving method dialog 2024-07-27 15:07:26 +06:00
wukko
7411f358d2 web/DialogButtons: move elevated button styling to layout 2024-07-27 15:03:37 +06:00
wukko
778190b2b3 web/dialogs: create a container for reused code 2024-07-27 14:24:24 +06:00
wukko
0a7747c497 web/dialogs: move duplicated dialog css to parent 2024-07-27 12:53:57 +06:00
dumbmoron
82ecf16d79 web/donate: disable padding-left for wallets on mobile 2024-07-26 17:06:03 +00:00
dumbmoron
b3d8a9bf1c web/donate: minor css fixes 2024-07-26 17:00:01 +00:00
wukko
7427788efd web/PickerItem: add support for gifs in picker 2024-07-26 21:34:18 +06:00
dumbmoron
e0bc0553ca web/donate: fix price padding 2024-07-26 09:05:14 +00:00
dumbmoron
8ac834ec80 web: initial donate page 2024-07-26 08:22:30 +00:00
dumbmoron
809178d6d8 web: import italic normal version of ibm-plex-mono 2024-07-26 08:22:21 +00:00
dumbmoron
7a5e4c56d3 web/settings: make old settings migration use initial schema 2024-07-24 10:27:06 +00:00
wukko
49973eceb1 web/omnibox: disable input area when processing & reduce timeout
also fixed a typo that broke key spamming protection
2024-07-26 14:07:23 +06:00
dumbmoron
f8d06cf18b web/settings: settings import/export 2024-07-23 18:17:38 +00:00
wukko
6e24a8d172 web/PickerItem: enable video thumb mode only when item is a video 2024-07-23 22:11:25 +06:00
dumbmoron
5e26c1e122 web/PickerItem: small refactor 2024-07-23 16:07:12 +00:00
dumbmoron
690ae835b4 web/ChangelogEntry: hide focus ring for version 2024-07-23 12:18:18 +00:00
dumbmoron
3218adf5fd web/changelogs: focus on version on page load 2024-07-23 11:42:13 +00:00
wukko
b540703de8 web/UpdateNotification: increase icon size 2024-07-23 16:46:18 +06:00
wukko
8b6775ca86 web/UpdateNotification: reduce right padding 2024-07-23 16:43:29 +06:00
dumbmoron
c32a5301a0 web/updates: make page scrollable on navigation sides on desktop 2024-07-23 10:32:18 +00:00
dumbmoron
61e47b38d1 web/DownloadButton: slight refactor 2024-07-23 10:09:42 +00:00
wukko
2d7d4cf091 web: add an update notification 2024-07-23 16:04:43 +06:00
dumbmoron
89181c6ddc web/settings: make version info optional, wait for load 2024-07-23 09:36:49 +00:00
wukko
23c9eb73aa web: global data-focus-ring-hidden attribute 2024-07-23 14:41:55 +06:00
wukko
19ee8360c4 web/updates: revert div -> main to fix html hierarchy 2024-07-23 14:19:22 +06:00
wukko
0d34bc0ab3 web/updates: focus the main content & fix ghost buttons 2024-07-23 14:13:05 +06:00
wukko
a9e8ea1306 web/Placeholder: focus first on page 2024-07-23 14:08:34 +06:00
wukko
f2de69f153 web/save: focus the page content on navigation 2024-07-23 14:06:44 +06:00
wukko
1234cc1083 web/settings: focus the page after navigation
mobile: focus page header
desktop: focus page content
2024-07-23 14:04:19 +06:00
wukko
c9ca0d51d9 web: add first focus functionality
element with `data-first-focus` will be focused first after navigation. extremely useful for screen readers.
2024-07-23 13:53:43 +06:00
wukko
314d3590ec web/DialogButtons: don't apply hover effect if button is colored 2024-07-23 13:22:05 +06:00
wukko
c12088e297 web/SmallDialog: flex container for header & icon
fixes stranded padding
2024-07-23 13:18:08 +06:00
wukko
836da67f19 web/changelogs: remove 3.3 changelog as it's a duplicate of 3.4 2024-07-23 12:44:13 +06:00
wukko
94853f0b7b web/FilenamePreview: finish the component 2024-07-23 12:34:14 +06:00
wukko
518f634385 web/settings: reduce thickness of back button icon 2024-07-23 12:20:30 +06:00
wukko
5c6ef19132 web/settings: update the back button icon 2024-07-23 12:19:38 +06:00
wukko
48078e7e75 web/updates: replace chevron with arrow 2024-07-23 12:19:12 +06:00
wukko
ee162aa236 web/ClearButton: fix rendering bug in safari & clean up 2024-07-23 12:13:40 +06:00
wukko
0225a7c46c web/settings: simplify sidebar, add version info, flatten page navigation 2024-07-23 11:00:27 +06:00
wukko
8c96ccbc7b web/SmallDialog: make body scrollable on overflow & limit height 2024-07-23 10:01:55 +06:00
wukko
bdd572ea51 web/dialogs: reduce margin on mobile 2024-07-23 09:59:08 +06:00
wukko
7ee99ad30f web/Skeleton: add elevated skeleton and use it for picker item
- fixed bg not being visible when shimmer is not on the element in dark theme
- fixed stuck gradient when motion is reduced
- fixed big skeleton
- skeleton is no longer focusable
2024-07-23 09:55:55 +06:00
wukko
718dc4cf0a web/DownloadButton: darken when disabled 2024-07-22 16:42:37 +06:00
wukko
2fb05d018c web/DownloadButton: show done button state for picker 2024-07-22 16:35:36 +06:00
wukko
bc8e3d4a7c web/Omnibox: simplify link state storage 2024-07-22 16:34:19 +06:00
dumbmoron
44243cc4c2 web/PickerDialog: fix typo 2024-07-22 14:17:33 +00:00
dumbmoron
d170f619d2 web: use conditionals instead of special classes where it makes sense 2024-07-22 10:17:06 +00:00
wukko
b0a69f9944 web/PickerItem: always show type badge, prevent right click on video
- fixed type badge centering in webkit
- increased border radius of type badge to match icons inside it
2024-07-22 16:14:26 +06:00
dumbmoron
c03337fed9 web/skeleton: don't render if hidden 2024-07-22 10:11:23 +00:00
dumbmoron
705fac16a6 web/dialog: internal refactor 2024-07-22 09:24:17 +00:00
wukko
9787a04e19 web/picker: add item type icons and improve accessibility 2024-07-22 15:13:51 +06:00
dumbmoron
7c5b703e37 web/dialog: refactor types and logic 2024-07-22 09:06:11 +00:00
dumbmoron
4e4f7af437 web/settings: types for preparation for future migrations 2024-07-22 08:38:06 +00:00
wukko
66bac03e30 web/dialogs: add picker dialog & clean up small dialog 2024-07-22 14:33:43 +06:00
wukko
24b783e5fb web/Omnibox: remember the link input when switching between tabs 2024-07-22 09:55:04 +06:00
dumbmoron
787fe72340 web/ChangelogEntry: fix warning about unused css selector 2024-07-21 17:40:51 +00:00
dumbmoron
bb446ecf3e web: add Optional type and use it 2024-07-21 17:26:21 +00:00
wukko
f93f3cd558 web/DownloadButton: fallback if status isn't supported 2024-07-21 22:49:26 +06:00
dumbmoron
9b4f593f87 web/changelogs: add more historical changelogs 2024-07-21 15:53:33 +00:00
wukko
4402484a0c web/updates: reduce mobile navigation padding 2024-07-21 17:34:31 +06:00
dumbmoron
4fab0d3fb8 web/ChangelogEntry: expect date to always exist 2024-07-21 11:31:55 +00:00
dumbmoron
534af330ce web/changelogs: make date attribute required 2024-07-21 11:30:06 +00:00
dumbmoron
ebaa209c47 web/changelogs: add dates to all changelogs 2024-07-21 11:29:57 +00:00
wukko
213f2d2c92 web/updates: hide navigation buttons when nowhere to navigate
- removed box shadow on desktop
- centered button vertically with flex
2024-07-21 17:22:22 +06:00
wukko
88fa780f6d web/layout: add dark theme skeleton gradient 2024-07-21 17:06:47 +06:00
wukko
238cd22c8d web/ChangelogSkeleton: fix title skeleton cutting off, reduce rounding 2024-07-21 16:57:42 +06:00
wukko
d8acb5406a web/layout: fix skeleton gradient 2024-07-21 16:49:38 +06:00
dumbmoron
8366a9d9b6 web/changelog: use same stylesheet for skeleton and loaded entry 2024-07-21 10:06:43 +00:00
dumbmoron
20320c1935 web: use svelte-preprocess instead of vitePreprocess
this allows us to use <style src={...}></style> also for scoped styles
(and not just global styles)
2024-07-21 10:06:16 +00:00
dumbmoron
0cea58922d web/changelogs: display skeleton when changelog is loading 2024-07-21 09:42:48 +00:00
wukko
f530624467 web/ResetSettingsButton: update dialog text
erase -> reset
2024-07-21 16:40:39 +06:00
wukko
ec768ebfc2 web/settings/metadata: basic filename preview component 2024-07-21 16:34:37 +06:00
wukko
edd1137228 web/ChangelogEntry: deduplicate padding when banner isn't visible 2024-07-21 14:45:24 +06:00
wukko
dbbd43e002 web/changelog: move components to dedicated folder 2024-07-21 14:42:17 +06:00
wukko
2efaa11670 web/DialogHolder: clean up 2024-07-21 14:37:03 +06:00
wukko
782752fd60 web/OuterLink: accept rel but ignore it 2024-07-21 14:34:29 +06:00
wukko
a7f40d708e web/meowbalt: hide meowbalt from screen readers
presence of an image of a cat on the screen doesn't change anything about the ui for vision impaired people. it's unnecessary and potentially annoying to have the description of it read out loud on every screen it's used.
2024-07-21 13:59:27 +06:00
wukko
f07aac301c web/SmallDialog: focus title first when it's visible 2024-07-21 13:49:22 +06:00
wukko
80300bf766 web/dialogs: moved backdrop to dialog holder, improved animation 2024-07-21 13:41:14 +06:00
dumbmoron
8a080c55f6 web: refactor and deduplicate locale preference logic 2024-07-20 14:37:52 +00:00
wukko
d4d4eded32 web/SmallDialog: make button text white if its bg is red 2024-07-20 21:56:43 +06:00
wukko
d7bf98a80b web: settings reset confirmation, icons for small dialog
- cleaned up dialog i18n
- better red color
- made :active state visible for dialog buttons on mobile
- better body padding in small dialog
- better small dialog typing with optional values
2024-07-20 21:48:17 +06:00
dumbmoron
def6e26b9f web/settings: add "erase all settings" button 2024-07-20 14:01:13 +00:00
wukko
720b3c5f68 web: full SmallDialog component, one flexible meowbalt component
- fully stylized small dialog: header, title, subtext, state without meowbalt
- moved meowbalt into his own adaptive component, no need to import/create new ones for each emotion
- better types for dialog related stuff
- type for meowbalt's emotions
- better padding in small dialog
2024-07-20 20:34:19 +06:00
wukko
e541bdc6d7 web/Switcher: fix ghost hover effect on active button
it used to appear very briefly when you pressed a button and didn't move the cursor off the button
2024-07-20 19:09:44 +06:00
wukko
8193e8c14d web/buttons: add hover state to active button 2024-07-20 18:54:12 +06:00
wukko
205494b367 web/SmallDialog: fix scaling on small screens 2024-07-20 18:49:51 +06:00
wukko
cd41fc9d49 web/SmallDialog: fix rendering & performance issues, new mobile animation 2024-07-18 17:22:29 +06:00
dumbmoron
2c1bd50e70 Revert "web/vite: change build target to esnext"
This reverts commit 842f91ec54.
2024-07-20 12:43:22 +00:00
dumbmoron
c3c7a6b7ba web/version: convert to readable 2024-07-20 12:43:14 +00:00
dumbmoron
0e60ea9582 web/ChangelogEntry: consistent date formatting 2024-07-17 09:07:25 +00:00
dumbmoron
0391ac7230 web/ChangelogEntryWrapper: use OuterLink component for links 2024-07-17 08:59:45 +00:00
dumbmoron
dd16fb65cf web/changelogs: update all links to markdown format 2024-07-17 08:59:10 +00:00
wukko
ce609ad201 web/SmallDialog: allow text selection in body 2024-07-17 14:50:35 +06:00
wukko
204e025656 web/layout: allow text selection in subtext 2024-07-17 14:50:09 +06:00
wukko
ce95135234 web/ChangelogEntry: allow text selection 2024-07-17 14:49:25 +06:00
wukko
691a6acb6c web/ChangelogEntry: slightly reduce font size on mobile 2024-07-17 14:41:44 +06:00
wukko
cf4ee05e0e web/updates: reduce top padding on mobile 2024-07-17 14:38:02 +06:00
wukko
dd7c17efeb web/ChangelogEntry: reduce the size of heading contents 2024-07-17 14:34:03 +06:00
wukko
6a7311874e web/changelog: make main body text easier to read 2024-07-17 14:29:46 +06:00
wukko
6e374fde62 web/updates: make changelogs look nicer
- fixes horizontal scrolling on mobile
- removes text backdrop
- improves readability
2024-07-17 14:04:53 +06:00
wukko
1ec9d92eb2 web/updates: clean up components 2024-07-17 13:32:07 +06:00
dumbmoron
8eee024899 web/updates: don't prevent default action for keyboard inputs 2024-07-17 07:30:22 +00:00
wukko
e6ec8c6734 web/package: fix the svelte icons version & update lock 2024-07-17 13:14:56 +06:00
dumbmoron
5c07afe4ff web/updates: keyboard navigation 2024-07-16 17:27:43 +00:00
dumbmoron
678adfbda4 web/updates: proper navigation buttons, refactor internal nav logic 2024-07-16 17:25:21 +00:00
dumbmoron
3305bba28a web/updates: update hash on navigation, navigate if present on load 2024-07-16 17:21:53 +00:00
dumbmoron
a22b0e5136 web/ChangelogEntry: initial css 2024-07-16 17:19:17 +00:00
dumbmoron
766482c21a frontend: setup initial updates page 2024-06-29 18:23:56 +00:00
dumbmoron
3aa17733d1 frontend: install and configure mdsvex 2024-06-29 17:58:00 +00:00
dumbmoron
a856983810 web: convert changelogs from old format
https://gist.github.com/dumbmoron/3fc6c0c747d791928aba939976fd9304
2024-06-29 17:36:26 +00:00
dumbmoron
e1a898bd58 frontend: move update banners to new frontend 2024-06-27 23:18:52 +00:00
wukko
8ebde39197 web/Omnibox: prevent paste button spamming 2024-07-16 22:17:51 +06:00
wukko
504dfdb995 web/Omnibox: ignore keyboard shortcuts when processing 2024-07-16 22:11:57 +06:00
wukko
d31090c3d5 web/Omnibox: ignore keyboard shortcuts when dialog is visible 2024-07-16 22:03:16 +06:00
wukko
fa835d0010 web/dialog: css for small dialog
- moved backdrop to each dialog
- dialog is now closable by clicking the backdrop
- added meowbalt to dialogs
- added more meowbalt assets & components
- added "main" boolean to indicate the main action button in a list of buttons
2024-07-16 14:00:56 +06:00
wukko
03bd995839 web/Omnibox: disable focus ring on inner input element 2024-07-15 14:29:02 +06:00
wukko
25cfa3e443 web/Omnibox: add extraction of links from page hash and query 2024-07-15 14:21:51 +06:00
wukko
f9dc8096bc web: always show focus ring 2024-07-14 23:21:04 +06:00
wukko
6ff874d5a1 web/i18n/settings: fix a typo in analytics description 2024-07-14 23:05:31 +06:00
wukko
128ab388f3 web: add env variable & plausible functionality 2024-07-14 22:50:18 +06:00
dumbmoron
436b735d2a web/settings: try to migrate settings if new format is not used yet 2024-07-13 20:39:40 +00:00
dumbmoron
5a630c2320 web/migrate: set up migration from old settings format 2024-07-13 20:37:38 +00:00
wukko
9f649ff1db web/settings: update analytics text & add a link to more info 2024-07-14 21:51:56 +06:00
wukko
f582be5d44 web/i18n/save: remove "the" from terms note 2024-07-14 21:27:34 +06:00
dumbmoron
4168998b93 web/settings: add "privacy" page with analytics toggle 2024-07-14 15:26:55 +00:00
dumbmoron
ee9421b831 web/state/settings: add "disable analytics" option 2024-07-14 15:22:42 +00:00
wukko
578150e40e web: add dynamic lang html tag 2024-07-14 21:10:41 +06:00
wukko
ad3703ab73 web/settings: fix header on mobile at certain scales 2024-07-14 20:52:22 +06:00
wukko
1712dc8948 web: switch main body color depending on theme 2024-07-14 20:42:32 +06:00
wukko
36c11556d7 web/i18n/save: fix grammar in terms note 2024-07-14 20:33:49 +06:00
dumbmoron
1f0958a0d1 web/settings: move to state folder 2024-07-13 13:17:03 +00:00
wukko
4232c3437b web: dialog system & basic small dialog 2024-07-13 19:15:43 +06:00
wukko
c5fbff560b web/debug: show all data as json 2024-07-13 19:07:35 +06:00
dumbmoron
15caad7e36 web: adjust gray color to pass WCAG AA guideline 2024-07-13 12:51:31 +00:00
dumbmoron
3f98f5bee8 settings: move schemaVersion definition to updateSetting 2024-07-13 12:32:08 +00:00
dumbmoron
f4aff44004 debug: include settings json on page 2024-07-13 12:25:50 +00:00
dumbmoron
2cce4bd521 settings: expose settings that have only been modified
also reduce unnecessary loads from storage
2024-07-13 12:25:27 +00:00
dumbmoron
2a0366a58d settings: add migrations, include schemaVersion in storage 2024-07-13 12:05:52 +00:00
wukko
6c9d759a3a web: update save page i18n & add link to terms and ethics of use
now also styling <a> properly, with exceptions only when needed
2024-07-13 13:45:53 +06:00
wukko
151fdad047 web/Sidebar: make bottom padding match the corner radius of the page 2024-07-12 20:49:44 +06:00
wukko
ce740770bc web/Sidebar: remove duplicate padding in css 2024-07-12 20:46:46 +06:00
wukko
96be9ffbc3 web/sidebar: redo padding on mobile & desktop
- accommodate space for scaling animation
- replace static padding with variable in calculations
- no more weird padding on mobile
2024-07-12 20:22:12 +06:00
wukko
914be64153 web/settings: make navigation scrollable on overflow 2024-07-12 19:18:47 +06:00
wukko
5ccde7995e web: convert global state classes to data attributes
also cleaned up unnecessary dupe in sidebar component
2024-07-12 19:15:55 +06:00
wukko
b12ad56cc1 web/LanguageAutoToggle: update preferred language variable name 2024-07-12 19:02:23 +06:00
wukko
d27bed7add web: respect reduced motion & transparency preferences
also cleaned up settings & device libs
2024-07-12 18:49:29 +06:00
wukko
1160b90c17 web/SidebarTab: apply will-change only on iphone 2024-07-12 17:16:26 +06:00
wukko
459c746dcc web/SidebarTab: yet another attempt to fix blurriness on small res screens 2024-07-12 17:06:05 +06:00
wukko
27082cd725 web/SidebarTab: go back to neutral transform state at end of animation 2024-07-11 12:26:39 +06:00
dumbmoron
aea7ebb371 LanguageAutoToggle: change language even if it does not exist
if the locfile does not exist, it will just fall back to english
2024-07-10 18:46:43 +00:00
wukko
4759f2037c web/device: add user agent 2024-07-11 00:27:46 +06:00
wukko
02437a686c web/i18n/settings: update language description
added information about translation fallback
2024-07-11 00:21:49 +06:00
wukko
7648c115e0 web/vite: change build target to esnext 2024-07-11 00:05:29 +06:00
wukko
936da1c9ab web/debug: show page content only when debug mode is enabled 2024-07-10 23:52:44 +06:00
wukko
6c7695ca6c web/error: redirect home on 404 2024-07-10 23:50:50 +06:00
dumbmoron
95bcf7bf66 settings: only store settings if changed by user 2024-07-10 17:47:46 +00:00
wukko
a6ddceb643 web/settings: add advanced & debug pages 2024-07-10 23:35:53 +06:00
wukko
49f9057b6b web/SettingsNavSection: make section title optional 2024-07-10 23:35:40 +06:00
wukko
2b907e5684 web/lib/settings: use default settings as base when loading from storage 2024-07-10 23:29:47 +06:00
wukko
bc63b0c6b7 web/lib/device: fix ipad recognition 2024-07-10 23:28:23 +06:00
dumbmoron
d1767c550c version.json: refactor, don't use error(), use cf pages env if available 2024-07-10 16:23:35 +00:00
dumbmoron
a5d87edeca version.json: correct parsing for https clones 2024-07-10 16:14:54 +00:00
dumbmoron
23bcd6076a web: add prerendered version.json endpoint for frontend metadata 2024-07-10 16:04:00 +00:00
wukko
b92579ea2c web/LanguageDropdown: yet another fix for chrome on windows 2024-07-10 21:54:04 +06:00
wukko
3a531713d0 web/SidebarTab: make the button squishy 2024-07-10 21:46:14 +06:00
wukko
f498ea65b0 web/i18n/settings: improve reduce motion description 2024-07-10 20:21:41 +06:00
wukko
6c2d147bc6 web/settings: clean up the mobile header 2024-07-10 20:19:46 +06:00
wukko
e52340f33a web/settings: improve subtext visibility 2024-07-10 20:19:05 +06:00
wukko
ceabce864f web/i18n/settings: remove "preferred" from titles 2024-07-10 19:37:49 +06:00
wukko
c013134b70 web/settings: move switcher description to correct component 2024-07-10 19:35:23 +06:00
wukko
d8420116dc web/LanguageDropdown: fix option style in chrome on windows 2024-07-10 18:42:00 +06:00
wukko
496d440e5b web/settings: refresh the locale state when auto mode is toggled 2024-07-09 21:41:53 +06:00
wukko
82ac838655 web: use credentials for manifest.json 2024-07-09 21:16:40 +06:00
wukko
19a0b00853 web/i18n: add fallback locale 2024-07-09 17:40:45 +06:00
wukko
1bf0e6707b web/settings/appearance: rearrange sections 2024-07-08 19:47:59 +06:00
wukko
cbc1febab2 web/settings: disable the language dropdown when auto 2024-07-08 19:46:20 +06:00
wukko
bd2bdf326f web/Omnibox: handle enter key press 2024-07-08 19:29:30 +06:00
wukko
05044922d5 web/LanguageDropdown: fix current selection string 2024-07-08 19:24:40 +06:00
wukko
6570d163e9 web/SidebarTab: indicate that tab is selected in aria 2024-07-08 12:58:21 +06:00
wukko
862366b5c5 web/LanguageDropdown: fix missing text in firefox & small font size in safari 2024-07-08 12:49:27 +06:00
wukko
bad7e3307d web/LanguageDropdown: proper component style
you can't toggle the "select" element programmatically, so i had to come up with a workaround. it works and looks beautifully!

also fixed buggy overflow in SettingsToggle component.
2024-07-08 00:18:25 +06:00
wukko
35a8628cc1 web/SettingsToggle: change aria role to switch 2024-07-07 22:45:35 +06:00
dumbmoron
da1a11b5ce svelte: don't use relative paths for bundle links in html
this prevents a blank page from showing up when a user
visits a non-existing page on a static build
2024-07-29 10:13:45 +00:00
wukko
d22230b1d5 web/settings: highlight the setting when linked to
- remade the way padding in settings is done to accommodate space for a highlight
- renamed nav components to indicate better what they are
2024-07-07 21:51:46 +06:00
wukko
430bfaca43 web/settings: add section ids 2024-07-07 19:14:49 +06:00
wukko
9b3f289b0e web/lib/api: don't follow redirects away from api 2024-07-07 18:52:06 +06:00
dumbmoron
a6a51b850a web/chore: tabs to spaces
idk how this happened :-3
2024-07-04 22:27:38 +00:00
dumbmoron
9ae0473f80 web/sidebar: simplify settings link logic 2024-07-04 22:25:47 +00:00
dumbmoron
157b687ab5 web/settings: redirect to full page if base page is opened on desktop 2024-07-04 22:25:22 +00:00
dumbmoron
16c76e7e92 web/settings: redirect invalid settings paths to default settings page 2024-07-04 22:18:02 +00:00
dumbmoron
e98f76c8ee web/build: merge i18n chunks for each language into one file 2024-07-04 22:15:15 +00:00
wukko
422b907703 web/i18n/settings: update saving.ask.description 2024-07-04 19:09:02 +06:00
dumbmoron
8fd2c66441 web/i18n: dynamically determine languages from i18n folder contents 2024-07-03 19:28:44 +00:00
wukko
3e9296ac1e web: remove legacy global navigation shortcuts 2024-07-04 00:12:30 +06:00
wukko
8b801bad50 web/save: keyboard shortcut for muted mode 2024-07-04 00:03:46 +06:00
wukko
97d381e993 web: move all strings to i18n & improve a11y
- omnibox is now fully usable with a screen reader
- back button is now interpreted as such
- subtext now accepts line breaks
2024-07-03 23:54:44 +06:00
dumbmoron
70339b7ae9 web: handle global keyboard shortcuts 2024-07-03 17:51:01 +00:00
dumbmoron
743338ea4c web/omnibox: add keyboard shortcuts support
- shift+d to paste
    - ⌘/ctrl+v to paste
    - shift+k for auto mode
    - shift+l for audio mode
    - esc to clear input

todo:
    - shortcut for "muted" mode
2024-07-03 17:42:34 +00:00
dumbmoron
9c4a4fb5a1 web: fix sveltekit warning about body directly in <body> 2024-07-03 17:06:27 +00:00
dumbmoron
d0f78eda53 manifest: fix chrome warnings 2024-07-03 17:05:42 +00:00
wukko
374611553b web: add notch easter egg & optimize for landscape
it took way too much time to optimize the damn logo sticker under notch for all devices & zoom states

also improved device lib api
2024-07-03 19:05:14 +06:00
wukko
901f0a7480 web/settings: more accessibility improvements 2024-07-03 17:10:53 +06:00
wukko
a478993599 web: improve screen reader usability
- switchers now have audible states
- toggles are now interpreted as toggles
- fixed weird spacing introduced in last commit
2024-07-03 14:09:09 +06:00
wukko
5ced7b5388 web/save: move strings to i18n & translate to ru
also fixed line break in switcher for future lengthier translations (german, for example)
2024-07-03 13:52:27 +06:00
wukko
9939f3b172 web: i18n system & navbar translations
dynamic page language and language dropdown!! finally!!
2024-07-03 00:16:03 +06:00
wukko
d11874e57f web/layout: update input border color for light theme 2024-07-02 19:25:37 +06:00
wukko
567cfe05ec web/settings: unfuck tab padding on mobile 2024-06-30 15:58:40 +06:00
wukko
7dd33d1341 web/layout: move main bg coloring to #cobalt 2024-06-29 23:46:28 +06:00
wukko
3527131cd7 web/settings: calculate item padding properly 2024-06-29 23:31:40 +06:00
wukko
a1913988d7 web/settings: adjust padding for switcher & toggle 2024-06-29 23:02:10 +06:00
wukko
0c33ac3a1c web/SettingsToggle: clean up 2024-06-29 22:53:09 +06:00
wukko
ad6539e3bd web/settings: replace checkbox with toggle
- equal font size & padding for all subtexts in settings
- equal padding & border radius for all settings components

it just looks way better now
2024-06-29 22:51:24 +06:00
wukko
c7befcb100 web/Switcher: new style & clean up 2024-06-29 21:19:35 +06:00
wukko
f383f5d94e web/theme: add dynamic status bar color on mobile 2024-06-29 20:24:51 +06:00
wukko
d817888838 web/device: add global constant for device info 2024-06-29 20:24:14 +06:00
wukko
10a9c955d9 web: proper theming 2024-06-29 20:09:17 +06:00
wukko
2a1344f93d web: update meowbalt smile asset 2024-06-28 21:57:57 +06:00
wukko
b2652f29ac web/Omnibox: download right after pasting 2024-06-25 22:52:17 +06:00
wukko
d008bffc08 web/DownloadButton: open share sheet on ios 2024-06-25 22:25:29 +06:00
wukko
98b0a2f10a web/SettingsCheckbox: remove yassing 2024-06-25 21:06:07 +06:00
wukko
635561394c web: add dynamic page titles 2024-06-25 21:01:08 +06:00
wukko
7b289bfb16 web: mobile improvements
- all buttons now reflect that they're pressed or hovered
- settings feel way better on mobile
- settings header has been completely remade
2024-06-25 20:59:25 +06:00
wukko
49e2df425d web: remove future feature placeholders 2024-06-25 19:41:38 +06:00
wukko
1f88a211aa web/SettingsCheckbox: proper checkbox style 2024-06-25 19:34:28 +06:00
wukko
ba2d0bb67f web: fix app height & overscroll 2024-06-25 16:14:54 +06:00
wukko
19661f2f72 web/save: fix overflow 2024-06-25 15:54:33 +06:00
wukko
352b0eae59 web/SettingsTab: fix overflow & chevron scaling 2024-06-25 15:44:06 +06:00
wukko
2512c4c6be web: preload entire code 2024-06-25 15:32:36 +06:00
wukko
44f17e71bc web/settings: update placeholder 2024-06-25 14:54:46 +06:00
wukko
55515f0fb1 web/settings: mobile layout, better padding & borders 2024-06-25 14:50:59 +06:00
wukko
6fdc63a6c2 web/SettingsTab: fix border radius & padding 2024-06-25 13:07:43 +06:00
wukko
5b57c7601d web/Sidebar: don't center the scrollable list 2024-06-25 00:31:16 +06:00
wukko
2c63d431d5 web/Sidebar: dynamic settings tab link based on device type 2024-06-25 00:12:23 +06:00
wukko
dd1f9b512f web/settings: change the layout on mobile screen 2024-06-25 00:11:04 +06:00
wukko
f8ade2bf08 web/Omnibox: don't show focus stroke in link bar 2024-06-24 23:48:37 +06:00
wukko
56081db857 web: move svg icon params to css & clean up 2024-06-24 23:46:37 +06:00
wukko
b153c06294 web/SettingsTab: clean up 2024-06-24 23:22:30 +06:00
wukko
23911cbc92 web: global focus-visible 2024-06-24 23:22:19 +06:00
wukko
042d2e9cc8 web: settings ui & const for settings type options 2024-06-24 23:05:51 +06:00
wukko
0064bda4ed web: proper text styling & semantics 2024-06-24 20:26:45 +06:00
wukko
a226f0635f web: use an alias for components folder 2024-06-24 20:23:55 +06:00
wukko
530edee0b1 web/settings: update main page placeholder 2024-06-24 19:44:06 +06:00
wukko
a12655a834 web/settings: navigation draft
also unified "active" class/state across all components & added more colors
2024-06-24 19:42:31 +06:00
wukko
0372e8df47 web/lock: add engine requirements 2024-06-24 15:47:40 +06:00
wukko
e305c99b94 web/sidebar: highlight the category tab even on subpages 2024-06-24 15:47:20 +06:00
wukko
eb12fa631b web: update favicon to be more rounded 2024-06-24 14:04:24 +06:00
wukko
7e39bd78d7 web/settings: fix setting value name 2024-06-20 19:19:57 +06:00
wukko
b9e7661b6d web: basic settings page needed for testing
typescript cries about types but i don't care at this point
2024-06-20 18:05:17 +06:00
wukko
f2e74b681b web/sidebar: align tabbar to center on mobile 2024-06-20 13:46:01 +06:00
wukko
4564f409aa web/types/settings: add missing 480p video quality 2024-06-19 23:42:52 +06:00
wukko
3b2178fd1a web/api: full api request with user preferences 2024-06-19 23:29:26 +06:00
wukko
00cdb2121d web: data-driven switcher & save mode switcher
also:
- disabled ssr to enable localstorage
- removed the workaround for hover, as it looks bad
2024-06-19 23:04:09 +06:00
wukko
0ce73e03d3 web/package: update required node version to 20.9 2024-06-19 21:30:56 +06:00
wukko
1cac70f795 web/package: lowball engine requirements 2024-06-19 21:26:13 +06:00
wukko
b15b108fa9 web/package: add min engine versions 2024-06-19 21:20:18 +06:00
wukko
009a2cc863 web: implement settings core
this was a torture
2024-06-19 21:12:51 +06:00
wukko
21e03a407c web: add eslint 2024-06-19 17:55:06 +06:00
wukko
068af6a965 web/types/api: add trailing commas 2024-06-19 15:28:36 +06:00
wukko
8ec4a528ef web/save: fix terms note padding on mobile 2024-06-17 19:41:45 +06:00
wukko
838cc508de web/save: reduce meowbalt padding 2024-06-17 19:10:10 +06:00
wukko
ddb52cfef7 web/save: dynamic paste text & component clean up 2024-06-17 19:03:26 +06:00
wukko
9aa2de9bfd web/save: scale terms note on mobile screen 2024-06-17 18:52:18 +06:00
wukko
b97fd24bba web: improve button text legibility 2024-06-17 18:46:52 +06:00
wukko
eaf63fdd45 web: reduced omnibox & button sizes 2024-06-17 18:46:21 +06:00
wukko
f2bacc703a web/omnibox: import only one tabler icon 2024-06-17 01:18:39 +06:00
wukko
5390415aa7 web: use hover effects only when supported 2024-06-17 01:12:59 +06:00
wukko
95aeec3380 web: tab bar pagination effect & smooth scroll 2024-06-17 01:00:18 +06:00
wukko
2ea3ca1a07 web/sidebar: automatically scroll to active tab 2024-06-17 00:31:07 +06:00
wukko
65c14d41fa web: make tab focus blue for better visibility 2024-06-16 23:30:10 +06:00
wukko
ea830974b6 web: fix DownloadButton tab focus glow 2024-06-16 23:06:30 +06:00
wukko
5ba3231a1e web: consistent tab bar style with rounded corners 2024-06-16 22:59:16 +06:00
wukko
7679b84b2e web/sidebar: optimize tab bar for mobile 2024-06-16 22:26:06 +06:00
wukko
3fc7b99d05 web: add manifest, more icons, and some metadata 2024-06-16 22:00:26 +06:00
wukko
66a1e9e953 web/omnibox: prevent password manager autofill 2024-06-16 21:54:02 +06:00
wukko
f8f248f399 web: dark theme & coloring, border, focus fixes 2024-06-16 21:45:24 +06:00
wukko
2080a3e1ae web/sidebar: fix grid on mobile 2024-06-16 20:39:23 +06:00
wukko
72ac4c8f5a web/placeholder: more padding 2024-06-16 20:34:24 +06:00
wukko
382c6e1cd8 web: reusable meowbalt component & page placeholders 2024-06-16 20:32:09 +06:00
wukko
597320cb17 web: fix content padding 2024-06-16 20:15:17 +06:00
wukko
598ffc52b6 web/sidebar: fix scrolling one more time 2024-06-16 20:15:09 +06:00
wukko
1325c3516c web: move border radius into a variable 2024-06-16 19:51:02 +06:00
wukko
3a57c7165f web/sidebar: scroll only in one direction on overflow 2024-06-16 19:50:42 +06:00
wukko
f4485e2af0 web/html: update viewport rules 2024-06-16 19:46:15 +06:00
wukko
2549699a88 web/layout: hide all scrollbars 2024-06-16 19:25:30 +06:00
wukko
1fad4816b2 web: use static svelte adapter instead of auto 2024-06-16 19:21:26 +06:00
wukko
3c1a69063d web/sidebar: disable scrollbars 2024-06-16 19:11:44 +06:00
wukko
1c9d604326 web/sidebar: import individual files for icons 2024-06-16 19:10:58 +06:00
wukko
3554222f42 web: add api response types & clean up DownloadButton 2024-06-16 18:53:45 +06:00
wukko
1f2c28bd02 web: basic api interaction & downloading
download button now acts the way it should with various states
2024-06-16 18:22:44 +06:00
wukko
324729eb21 web: basic switcher component & mute mode button 2024-06-16 15:30:14 +06:00
wukko
bf26988cde web/save: add paste button & dummy mode buttons
tuned default button look, moved custom icons to lib for easy access
2024-06-15 20:39:34 +06:00
wukko
e6ffa4864c web: omnibox base with meowbalt 2024-06-14 21:48:57 +06:00
wukko
7cab37fc30 web: disable tap highlighting & user selection 2024-06-14 17:34:14 +06:00
wukko
92cccd720d web: mobile navigation 2024-06-14 17:33:33 +06:00
wukko
5399ee9a4c web: make sidebar scrollable on vertical overflow 2024-06-14 16:47:13 +06:00
wukko
b831dc8236 web: space out css 2024-06-14 16:38:10 +06:00
wukko
38d7add0a9 web: navigation & sidebar 2024-06-14 16:33:01 +06:00
wukko
fa85a4c75c readme: update license info 2024-07-29 15:29:13 +06:00
wukko
c5c0d44a2e web: add license 2024-07-29 15:26:04 +06:00
wukko
8cc11367ef web: project skeleton 2024-06-13 15:32:17 +06:00
596 changed files with 33726 additions and 11356 deletions

21
.dockerignore Normal file
View File

@@ -0,0 +1,21 @@
# OS directory info files
.DS_Store
desktop.ini
# node
node_modules
# static build
build
# secrets
.env
.env.*
!.env.example
cookies.json
# docker
docker-compose.yml
# ide
.vscode

View File

@@ -7,7 +7,7 @@ body:
value: |
thanks for taking the time to make a feature request!
before you start, please make to read the "adding features or support for services" section of
our [contributor guidelines](https://github.com/imputnet/cobalt/blob/current/CONTRIBUTING.md#adding-features-or-support-for-services) to make sure your request is a good fit for cobalt.
our [contributor guidelines](https://github.com/imputnet/cobalt/blob/main/CONTRIBUTING.md#adding-features-or-support-for-services) to make sure your request is a good fit for cobalt.
- type: textarea
id: feat-description
attributes:

View File

@@ -8,7 +8,7 @@ body:
value: |
thanks for taking the time to make a service request!
before you start, please make to read the "adding features or support for services" section of
our [contributor guidelines](https://github.com/imputnet/cobalt/blob/current/CONTRIBUTING.md#adding-features-or-support-for-services) to make sure your request is a good fit for cobalt.
our [contributor guidelines](https://github.com/imputnet/cobalt/blob/main/CONTRIBUTING.md#adding-features-or-support-for-services) to make sure your request is a good fit for cobalt.
- type: input
id: service-name
attributes:

39
.github/test.sh vendored
View File

@@ -13,19 +13,20 @@ waitport() {
test_api() {
waitport 3000
curl -m 3 http://localhost:3000/api/serverInfo
API_RESPONSE=$(curl -m 3 http://localhost:3000/api/json \
curl -m 3 http://localhost:3000/
API_RESPONSE=$(curl -m 10 http://localhost:3000/ \
-X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"url":"https://vine.co/v/huwVJIEJW50", "isAudioOnly": true}')
-d '{"url":"https://garfield-69.tumblr.com/post/696499862852780032","alwaysProxy":true}')
echo "$API_RESPONSE"
echo "API_RESPONSE=$API_RESPONSE"
STATUS=$(echo "$API_RESPONSE" | jq -r .status)
STREAM_URL=$(echo "$API_RESPONSE" | jq -r .url)
[ "$STATUS" = stream ] || exit 1;
[ "$STATUS" = tunnel ] || exit 1;
S=$(curl -I -m 10 "$STREAM_URL")
CONTENT_LENGTH=$(curl -I -m 3 "$STREAM_URL" \
CONTENT_LENGTH=$(echo "$S" \
| grep -i content-length \
| cut -d' ' -f2 \
| tr -d '\r')
@@ -37,37 +38,31 @@ test_api() {
fi
}
test_web() {
waitport 3001
curl -m 3 http://127.0.0.1:3001/onDemand?blockId=0 \
| grep -q '"status":"success"'
}
setup_api() {
export API_PORT=3000
export API_URL=http://localhost:3000
timeout 10 npm run start
timeout 10 pnpm run --prefix api start &
API_PID=$!
}
setup_web() {
export WEB_PORT=3001
export WEB_URL=http://localhost:3001
export API_URL=http://localhost:3000
timeout 5 npm run start
pnpm run --prefix web check
pnpm run --prefix web build
}
cd "$(git rev-parse --show-toplevel)"
npm i
pnpm install --frozen-lockfile
if [ "$1" = "api" ]; then
setup_api &
setup_api
test_api
[ "$API_PID" != "" ] \
&& kill "$API_PID"
elif [ "$1" = "web" ]; then
setup_web &
test_web
setup_web
else
echo "usage: $0 <api/web>" >&2
exit 1
fi
wait || exit $?
wait || exit $?

93
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,93 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches:
- '**'
pull_request:
branches: [ "main", "7" ]
schedule:
- cron: '33 7 * * 5'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View File

@@ -1,4 +1,4 @@
name: Build Docker development image
name: Build development Docker image
on:
workflow_dispatch:

55
.github/workflows/docker-staging.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Build staging Docker image
on:
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get release metadata
id: release-meta
run: |
version=$(cat package.json | jq -r .version)
echo "commit_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
echo "version=$version" >> $GITHUB_OUTPUT
echo "major_version=$(echo "$version" | cut -d. -f1)" >> $GITHUB_OUTPUT
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
tags: type=raw,value=staging
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,4 +1,4 @@
name: Build Docker image
name: Build release Docker image
on:
workflow_dispatch:
@@ -32,7 +32,7 @@ jobs:
- name: Get release metadata
id: release-meta
run: |
version=$(cat package.json | jq -r .version)
version=$(cat api/package.json | jq -r .version)
echo "commit_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
echo "version=$version" >> $GITHUB_OUTPUT
echo "major_version=$(echo "$version" | cut -d. -f1)" >> $GITHUB_OUTPUT
@@ -51,7 +51,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

37
.github/workflows/test-services.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Run service tests
on:
pull_request:
push:
paths:
- api/**
- packages/**
jobs:
check-services:
name: test service functionality
runs-on: ubuntu-latest
outputs:
services: ${{ steps.checkServices.outputs.service_list }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- id: checkServices
run: pnpm i --frozen-lockfile && echo "service_list=$(node api/src/util/test get-services)" >> "$GITHUB_OUTPUT"
test-services:
needs: check-services
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
service: ${{ fromJson(needs.check-services.outputs.services) }}
name: "test service: ${{ matrix.service }}"
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm i --frozen-lockfile
- run: node api/src/util/test run-tests-for ${{ matrix.service }}
env:
HTTP_PROXY: ${{ secrets.API_EXTERNAL_PROXY }}
TEST_IGNORE_SERVICES: ${{ vars.TEST_IGNORE_SERVICES }}

View File

@@ -3,60 +3,34 @@ name: Run tests
on:
pull_request:
push:
branches: [ current ]
jobs:
check-lockfile:
name: check lockfile correctness
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Check that lockfile does not need an update
run: |
cp package-lock.json before.json
npm ci
npm i --package-lock-only
diff before.json package-lock.json
run: pnpm install --frozen-lockfile
test-web:
name: web sanity check
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run test script
run: .github/test.sh web
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- uses: pnpm/action-setup@v4
- run: .github/test.sh web
env:
WEB_DEFAULT_API: https://api.dummy.example/
test-api:
name: api sanity check
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run test script
run: .github/test.sh api
check-services:
name: test service functionality
runs-on: ubuntu-latest
outputs:
services: ${{ steps.checkServices.outputs.service_list }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- id: checkServices
run: npm ci && echo "service_list=$(node src/util/test-ci get-services)" >> "$GITHUB_OUTPUT"
test-services:
needs: check-services
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
service: ${{ fromJson(needs.check-services.outputs.services) }}
name: "test service: ${{ matrix.service }}"
steps:
- name: Checkout repository
uses: actions/checkout@v4
- run: npm ci && node src/util/test-ci run-tests-for ${{ matrix.service }}
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: .github/test.sh api

19
.gitignore vendored
View File

@@ -1,21 +1,22 @@
# os stuff
# OS directory info files
.DS_Store
desktop.ini
# npm
# node
node_modules
# static build
build
# secrets
.env
.env.*
!.env.example
cookies.json
keys.json
# docker
docker-compose.yml
# vscode
# ide
.vscode
# cookie file
cookies.json
# page build
build

View File

@@ -4,7 +4,23 @@ if you're reading this, you are probably interested in contributing to cobalt, w
this document serves as a guide to help you make contributions that we can merge into the cobalt codebase.
## translations
currently, we are **not accepting** translations of cobalt. this is because we are making significant changes to the frontend, and the currently used localization structure is being completely reworked. if this changes, this document will be updated.
we are currently accepting translations via the [i18n platform](https://i18n.imput.net).
thank you for showing interest in making cobalt more accessible around the world, we really appreciate it! here are some guidelines for how a cobalt translation should look:
- cobalt's writing style is informal. please do not use formal language, unless there is no other way to express the same idea of the original text in your language.
- all cobalt text is written in lowercase. this is a stylistic choice, please do not capitalize translated sentences.
- do not translate the name "cobalt", or "imput"
- you can translate "meowbalt" into whatever your language's equivalent of _meow_ is (e.g. _miaubalt_ in German)
- **please don't translate cobalt into languages which you are not experienced in.** we can use google translate ourselves, but we would prefer cobalt to be translated by humans, not computers.
if your language does not exist on the translation platform yet, you can request to add it by adding it to any of cobalt's components (e.g. [here](https://i18n.imput.net/projects/cobalt/about/)).
before translating a piece of text, check that no one has made a translation yet. pending translations are displayed in the **Suggestions** tab on the translate page. if someone already made a suggestion, and you think it's correct, you can upvote it! this helps us distinguish that a translation is correct.
if no one has submitted a translation, or the submitted translation seems wrong to you, you can submit your translation by clicking the **Suggest** button for each individual string, which sends it off for human review. we will then check it to to ensure no malicious translations are submitted, and add it to cobalt.
if any translation string's meaning seems unclear to you, please leave a comment on the *Comments* tab, and we will either add an explanation or a screenshot.
## adding features or support for services
before putting in the effort to implement a feature, it's worth considering whether it would be appropriate to add it to cobalt. the cobalt api is built to assist people **only with downloading freely accessible content**. other functionality, such as:
@@ -22,9 +38,9 @@ when contributing code to cobalt, there are a few guidelines in place to ensure
### clean commit messages
internally, we use a format similar to [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) - the first part signifies which part of the code you are changing (the *scope*), and the second part explains the change. for inspiration on how to write appropriate commit titles, you can take a look at the [commit history](https://github.com/imputnet/cobalt/commits/).
the scope is not strictly defined, you can write whatever you find most fitting for the particular change. suppose you are changing a small part of a more significant part of the codebase. in that case, you can specify both the larger and smaller scopes in the commit message for clarity (e.g., if you were changing something in internal streams, the commit could be something like `stream/internal: fix object not being handled properly`).
the scope is not strictly defined, you can write whatever you find most fitting for the particular change. suppose you are changing a small part of a more significant part of the codebase. in that case, you can specify both the larger and smaller scopes in the commit message for clarity (e.g., if you were changing something in internal streams, the commit could be something like `api/stream: fix object not being handled properly`).
if you think a change deserves further explanation, we encourage you to write a short explanation in the commit message ([example](https://github.com/imputnet/cobalt/commit/d2e5b6542f71f3809ba94d56c26f382b5cb62762)), which will save both you and us time having to enquire about the change, and you explaining the reason behind it.
if you think a change deserves further explanation, we encourage you to write a short explanation in the commit message ([example](https://github.com/imputnet/cobalt/commit/31be60484de8eaf63bba8a4f508e16438aa7ba6e)), which will save both you and us time having to enquire about the change, and you explaining the reason behind it.
if your contribution has uninformative commit titles, you may be asked to interactively rebase your branch and amend each commit to include a meaningful title.

View File

@@ -1,15 +1,26 @@
FROM node:18-bullseye-slim
FROM node:24-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
FROM base AS build
WORKDIR /app
COPY . /app
RUN corepack enable
RUN apk add --no-cache python3 alpine-sdk
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --prod --frozen-lockfile
RUN pnpm deploy --filter=@imput/cobalt-api --prod /prod/api
FROM base AS api
WORKDIR /app
COPY package*.json ./
COPY --from=build --chown=node:node /prod/api /app
COPY --from=build --chown=node:node /app/.git /app/.git
RUN apt-get update && \
apt-get install -y git python3 build-essential && \
npm ci && \
npm cache clean --force && \
apt purge --autoremove -y python3 build-essential && \
rm -rf ~/.cache/ /var/lib/apt/lists/*
USER node
COPY . .
EXPOSE 9000
CMD [ "node", "src/cobalt" ]

186
README.md
View File

@@ -1,136 +1,72 @@
# cobalt
best way to save what you love: [cobalt.tools](https://cobalt.tools/)
<div align="center">
<br/>
<p>
<img src="web/static/favicon.png" title="cobalt" alt="cobalt logo" width="100" />
</p>
<p>
best way to save what you love
<br/>
<a href="https://cobalt.tools">
cobalt.tools
</a>
</p>
<p>
<a href="https://discord.gg/pQPt8HBUPu">
💬 community discord server
</a>
<br/>
<a href="https://x.com/justusecobalt">
🐦 twitter
</a>
<a href="https://bsky.app/profile/cobalt.tools">
🦋 bluesky
</a>
</p>
<br/>
</div>
![cobalt logo with repeated logo (double arrow) pattern background](/src/front/icons/pattern.png "cobalt logo with repeated logo (double arrow) pattern background")
cobalt is a media downloader that doesn't piss you off. it's friendly, efficient, and doesn't have ads, trackers, paywalls or other nonsense.
[💬 community discord server](https://discord.gg/pQPt8HBUPu)
[🐦 twitter/x](https://x.com/justusecobalt)
paste the link, get the file, move on. that simple, just how it should be.
## what's cobalt?
cobalt is a media downloader that doesn't piss you off. it's fast, friendly, and doesn't have any bullshit that modern web is filled with: ***no ads, trackers, or invasive analytics***.
### sponsors
<div align="center" markdown="1">
<sup>special thanks to Warp for sponsoring the development of cobalt</sup>
<br>
<a href="https://go.warp.dev/cobalt">
<img alt="Warp banner" width="400" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/be7d584f98e62b1579fd2e9338d4c7318a732f1b/Github/Sponsor/Warp-Github-LG-03.png">
</a>
paste the link, get the file, move on. it's that simple. just how it should be.
### [Warp, built for coding with multiple AI agents](https://go.warp.dev/cobalt)
</div>
## supported services
this list is not final and keeps expanding over time. if support for a service you want is missing, create an issue (or a pull request 👀).
#### RoyaleHosting
cobalt is sponsored by [royalehosting.net](https://royalehosting.net/?partner=cobalt), and a part of our infrastructure is hosted on their network. we really appreciate their kindness and support!
| service | video + audio | only audio | only video | metadata | rich file names |
| :-------- | :-----------: | :--------: | :--------: | :------: | :-------------: |
| bilibili.com & bilibili.tv | ✅ | ✅ | ✅ | | |
| dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ |
| instagram posts & reels | ✅ | ✅ | ✅ | | |
| facebook videos | ✅ | ❌ | ❌ | | |
| loom | ✅ | ❌ | ✅ | ✅ | |
| ok video | ✅ | ❌ | ✅ | ✅ | ✅ |
| pinterest | ✅ | ✅ | ✅ | | |
| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
| rutube | ✅ | ✅ | ✅ | ✅ | ✅ |
| snapchat stories & spotlights | ✅ | ✅ | ✅ | | |
| soundcloud | | ✅ | | ✅ | ✅ |
| streamable | ✅ | ✅ | ✅ | | |
| tiktok | ✅ | ✅ | ✅ | ❌ | ❌ |
| tumblr | ✅ | ✅ | ✅ | | |
| twitch clips | ✅ | ✅ | ✅ | ✅ | ✅ |
| twitter/x | ✅ | ✅ | ✅ | | |
| vimeo | ✅ | ✅ | ✅ | ✅ | ✅ |
| vine archive | ✅ | ✅ | ✅ | | |
| vk videos & clips | ✅ | ❌ | ✅ | ✅ | ✅ |
| youtube videos, shorts & music | ✅ | ✅ | ✅ | ✅ | ✅ |
### cobalt monorepo
this monorepo includes source code for api, frontend, and related packages:
- [api tree & readme](/api/)
- [web tree & readme](/web/)
- [packages tree](/packages/)
| emoji | meaning |
| :-----: | :---------------------- |
| ✅ | supported |
| | impossible/unreasonable |
| ❌ | not supported |
it also includes documentation in the [docs tree](/docs/):
- [how to run a cobalt instance](/docs/run-an-instance.md)
- [how to protect a cobalt instance](/docs/protect-an-instance.md)
- [cobalt api instance environment variables](/docs/api-env-variables.md)
- [cobalt api documentation](/docs/api.md)
### additional notes or features (per service)
| service | notes or features |
| :-------- | :----- |
| instagram | supports reels, photos, and videos. lets you pick what to save from multi-media posts. |
| facebook | supports public accessible videos content only. |
| pinterest | supports photos, gifs, videos and stories. |
| reddit | supports gifs and videos. |
| snapchat | supports spotlights and stories. lets you pick what to save from stories. |
| rutube | supports yappy & private links. |
| soundcloud | supports private links. |
| tiktok | supports videos with or without watermark, images from slideshow without watermark, and full (original) audios. |
| twitter/x | lets you pick what to save from multi-media posts. may not be 100% reliable due to current management. |
| vimeo | audio downloads are only available for dash. |
| youtube | supports videos, music, and shorts. 8K, 4K, HDR, VR, and high FPS videos. rich metadata & dubs. h264/av1/vp9 codecs. |
### ethics
cobalt is a tool that makes downloading public content easier. it takes **zero liability**.
the end user is responsible for what they download, how they use and distribute that content.
cobalt never caches any content, it [works like a fancy proxy](/api/src/stream/).
## cobalt api
cobalt has an open api that you can use in your projects *for free~*. it's easy and straightforward to use, [check out the docs](/docs/api.md) to learn how to use it.
cobalt is in no way a piracy tool and cannot be used as such.
it can only download free & publicly accessible content.
same content can be downloaded via dev tools of any modern web browser.
✅ you can use the main api instance ([api.cobalt.tools](https://api.cobalt.tools/)) in your **personal** projects.
❌ you cannot use the free api commercially (anywhere that's gated behind paywalls or ads). host your own instance for this.
### contributing
if you're considering contributing to cobalt, first of all, thank you! check the [contribution guidelines here](/CONTRIBUTING.md) before getting started, they'll help you do your best right away.
we reserve the right to restrict abusive/excessive access to the main instance api.
## how to run your own instance
if you want to run your own instance for whatever purpose, [follow this guide](/docs/run-an-instance.md).
it's *highly* recommended to use a docker compose method unless you run for developing/debugging purposes.
## partners
cobalt is sponsored by [royalehosting.net](https://royalehosting.net/?partner=cobalt), all main instances are currently hosted on their network :)
## ethics and disclaimer
cobalt is a tool for easing content downloads from internet and takes ***zero liability***. you are responsible for what you download, how you use and distribute that content. please be mindful when using content of others and always credit original creators. fair use and credits benefit everyone.
cobalt is ***NOT*** a piracy tool and cannot be used as such. it can only download free, publicly accessible content. such content can be easily downloaded through any browser's dev tools. pressing one button is easier, so i made a convenient, ad-less tool for such repeated actions.
## cobalt license
cobalt code is licensed under [AGPL-3.0](/LICENSE).
cobalt branding, mascots, and other related assets included in the repo are ***copyrighted*** and not covered by the AGPL-3.0 license. you ***cannot*** use them under same terms.
you are allowed to host an ***unmodified*** instance of cobalt with branding, but this ***does not*** give you permission to use it anywhere else, or make derivatives of it in any way.
### notes:
- mascots and other assets are a part of the branding.
- when making an alternative version of the project, please replace or remove all branding (including the name).
- you **must** link the original repo when using any parts of code (such as using separate processing modules in your project) or forking the project.
- if you make a modified version of cobalt, the codebase **must** be published under the same license (according to AGPL-3.0).
## 3rd party licenses
- [Fluent Emoji by Microsoft](https://github.com/microsoft/fluentui-emoji) (used in cobalt) is under [MIT](https://github.com/microsoft/fluentui-emoji/blob/main/LICENSE) license.
- [Noto Sans Mono](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/) fonts (used in cobalt) are licensed under the [OFL](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/about) license.
- many update banners were taken from [tenor.com](https://tenor.com/).
## acknowledgements
### ffmpeg
cobalt heavily relies on ffmpeg for converting and merging media files. it's an absolutely amazing piece of software offered for anyone for free, yet doesn't receive as much credit as it should.
you can [support ffmpeg here](https://ffmpeg.org/donations.html)!
#### ffmpeg-static
we use [ffmpeg-static](https://github.com/eugeneware/ffmpeg-static) to get binaries for ffmpeg depending on the platform.
you can support the developer via various methods listed on their github page! (linked above)
### youtube.js
cobalt relies on [youtube.js](https://github.com/LuanRT/YouTube.js) for interacting with the innertube api, it wouldn't have been possible without it.
you can support the developer via various methods listed on their github page! (linked above)
### many others
cobalt also depends on:
- [content-disposition-header](https://www.npmjs.com/package/content-disposition-header) to simplify the provision of `content-disposition` headers.
- [cors](https://www.npmjs.com/package/cors) to manage cross-origin resource sharing within expressjs.
- [dotenv](https://www.npmjs.com/package/dotenv) to load environment variables from the `.env` file.
- [esbuild](https://www.npmjs.com/package/esbuild) to minify the frontend files.
- [express](https://www.npmjs.com/package/express) as the backbone of cobalt servers.
- [express-rate-limit](https://www.npmjs.com/package/express-rate-limit) to rate limit api endpoints.
- [hls-parser](https://www.npmjs.com/package/hls-parser) to parse `m3u8` playlists for certain services.
- [ipaddr.js](https://www.npmjs.com/package/ipaddr.js) to parse ip addresses (for rate limiting).
- [nanoid](https://www.npmjs.com/package/nanoid) to generate unique (temporary) identifiers for each requested stream.
- [node-cache](https://www.npmjs.com/package/node-cache) to cache stream info in server ram for a limited amount of time.
- [psl](https://www.npmjs.com/package/psl) as the domain name parser.
- [set-cookie-parser](https://www.npmjs.com/package/set-cookie-parser) to parse cookies that cobalt receives from certain services.
- [undici](https://www.npmjs.com/package/undici) for making http requests.
- [url-pattern](https://www.npmjs.com/package/url-pattern) to match provided links with supported patterns.
...and many other packages that these packages rely on.
### licenses
for relevant licensing information, see the [api](api/README.md) and [web](web/README.md) READMEs.
unless specified otherwise, the remainder of this repository is licensed under [AGPL-3.0](LICENSE).

661
api/LICENSE Normal file
View File

@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
save what you love with cobalt.
Copyright (C) 2024 imput
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

104
api/README.md Normal file
View File

@@ -0,0 +1,104 @@
# cobalt api
this directory includes the source code for cobalt api. it's made with [express.js](https://www.npmjs.com/package/express) and love!
## running your own instance
if you want to run your own instance for whatever purpose, [follow this guide](/docs/run-an-instance.md).
we recommend to use docker compose unless you intend to run cobalt for developing/debugging purposes.
## accessing the api
there is currently no publicly available pre-hosted api.
we recommend [deploying your own instance](/docs/run-an-instance.md) if you wish to use the cobalt api.
you can read [the api documentation here](/docs/api.md).
## supported services
this list is not final and keeps expanding over time!
if the desired service isn't supported yet, feel free to create an appropriate issue (or a pull request 👀).
| service | video + audio | only audio | only video | metadata | rich file names |
| :-------- | :-----------: | :--------: | :--------: | :------: | :-------------: |
| bilibili | ✅ | ✅ | ✅ | | |
| bluesky | ✅ | ✅ | ✅ | | |
| dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ |
| instagram | ✅ | ✅ | ✅ | | |
| facebook | ✅ | ❌ | ✅ | | |
| loom | ✅ | ❌ | ✅ | ✅ | |
| newgrounds | ✅ | ✅ | ✅ | ✅ | ✅ |
| ok.ru | ✅ | ❌ | ✅ | ✅ | ✅ |
| pinterest | ✅ | ✅ | ✅ | | |
| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
| rutube | ✅ | ✅ | ✅ | ✅ | ✅ |
| snapchat | ✅ | ✅ | ✅ | | |
| soundcloud | | ✅ | | ✅ | ✅ |
| streamable | ✅ | ✅ | ✅ | | |
| tiktok | ✅ | ✅ | ✅ | ❌ | ❌ |
| tumblr | ✅ | ✅ | ✅ | | |
| twitch clips | ✅ | ✅ | ✅ | ✅ | ✅ |
| twitter/x | ✅ | ✅ | ✅ | | |
| vimeo | ✅ | ✅ | ✅ | ✅ | ✅ |
| vk videos & clips | ✅ | ❌ | ✅ | ✅ | ✅ |
| xiaohongshu | ✅ | ✅ | ✅ | | |
| youtube | ✅ | ✅ | ✅ | ✅ | ✅ |
| emoji | meaning |
| :-----: | :---------------------- |
| ✅ | supported |
| | unreasonable/impossible |
| ❌ | not supported |
### additional notes or features (per service)
| service | notes or features |
| :-------- | :----- |
| instagram | supports reels, photos, and videos. lets you pick what to save from multi-media posts. |
| facebook | supports public accessible videos content only. |
| pinterest | supports photos, gifs, videos and stories. |
| reddit | supports gifs and videos. |
| snapchat | supports spotlights and stories. lets you pick what to save from stories. |
| rutube | supports yappy & private links. |
| soundcloud | supports private links. |
| tiktok | supports videos with or without watermark, images from slideshow without watermark, and full (original) audios. |
| twitter/x | lets you pick what to save from multi-media posts. may not be 100% reliable due to current management. |
| vimeo | audio downloads are only available for dash. |
| youtube | supports videos, music, and shorts. 8K, 4K, HDR, VR, and high FPS videos. rich metadata & dubs. h264/av1/vp9 codecs. |
## license
cobalt api code is licensed under [AGPL-3.0](LICENSE).
this license allows you to modify, distribute and use the code for any purpose
as long as you:
- give appropriate credit to the original repo when using or modifying any parts of the code,
- provide a link to the license and indicate if changes to the code were made, and
- release the code under the **same license**
## open source acknowledgements
### ffmpeg
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)!
### youtube.js
cobalt relies on **[youtube.js](https://github.com/LuanRT/YouTube.js)** for interacting with youtube's innertube api, it wouldn't have been possible without this package.
you can support the developer via various methods listed on their github page!
(linked above)
### many others
cobalt-api also depends on:
- **[content-disposition-header](https://www.npmjs.com/package/content-disposition-header)** to simplify the provision of `content-disposition` headers.
- **[cors](https://www.npmjs.com/package/cors)** to manage cross-origin resource sharing within expressjs.
- **[dotenv](https://www.npmjs.com/package/dotenv)** to load environment variables from the `.env` file.
- **[express](https://www.npmjs.com/package/express)** as the backbone of cobalt servers.
- **[express-rate-limit](https://www.npmjs.com/package/express-rate-limit)** to rate limit api endpoints.
- **[ffmpeg-static](https://www.npmjs.com/package/ffmpeg-static)** to get binaries for ffmpeg depending on the platform.
- **[hls-parser](https://www.npmjs.com/package/hls-parser)** to parse HLS playlists according to spec (very impressive stuff).
- **[ipaddr.js](https://www.npmjs.com/package/ipaddr.js)** to parse ip addresses (used for rate limiting).
- **[nanoid](https://www.npmjs.com/package/nanoid)** to generate unique identifiers for each requested tunnel.
- **[set-cookie-parser](https://www.npmjs.com/package/set-cookie-parser)** to parse cookies that cobalt receives from certain services.
- **[undici](https://www.npmjs.com/package/undici)** for making http requests.
- **[url-pattern](https://www.npmjs.com/package/url-pattern)** to match provided links with supported patterns.
- **[zod](https://www.npmjs.com/package/zod)** to lock down the api request schema.
- **[@datastructures-js/priority-queue](https://www.npmjs.com/package/@datastructures-js/priority-queue)** for sorting stream caches for future clean up (without redis).
- **[@imput/psl](https://www.npmjs.com/package/@imput/psl)** as the domain name parser, our fork of [psl](https://www.npmjs.com/package/psl).
...and many other packages that these packages rely on.

50
api/package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "@imput/cobalt-api",
"description": "save what you love",
"version": "11.5",
"author": "imput",
"exports": "./src/cobalt.js",
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"start": "node src/cobalt",
"test": "node src/util/test",
"token:jwt": "node src/util/generate-jwt-secret"
},
"repository": {
"type": "git",
"url": "git+https://github.com/imputnet/cobalt.git"
},
"license": "AGPL-3.0",
"bugs": {
"url": "https://github.com/imputnet/cobalt/issues"
},
"homepage": "https://github.com/imputnet/cobalt#readme",
"dependencies": {
"@datastructures-js/priority-queue": "^6.3.1",
"@imput/psl": "^2.0.4",
"@imput/version-info": "workspace:^",
"content-disposition-header": "0.6.0",
"cors": "^2.8.5",
"dotenv": "^16.0.1",
"express": "^4.21.2",
"express-rate-limit": "^7.4.1",
"ffmpeg-static": "^5.1.0",
"hls-parser": "^0.10.7",
"ipaddr.js": "2.2.0",
"mime": "^4.0.4",
"nanoid": "^5.0.9",
"set-cookie-parser": "2.6.0",
"undici": "^6.21.3",
"url-pattern": "1.0.3",
"youtubei.js": "16.0.0",
"zod": "^3.23.8"
},
"optionalDependencies": {
"freebind": "^0.2.2",
"rate-limit-redis": "^4.2.0",
"redis": "^4.7.0"
}
}

37
api/src/cobalt.js Normal file
View File

@@ -0,0 +1,37 @@
import "dotenv/config";
import express from "express";
import cluster from "node:cluster";
import path from "path";
import { fileURLToPath } from "url";
import { env, isCluster } from "./config.js"
import { Red } from "./misc/console-text.js";
import { initCluster } from "./misc/cluster.js";
import { setupEnvWatcher } from "./core/env.js";
const app = express();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename).slice(0, -4);
app.disable("x-powered-by");
if (env.apiURL) {
const { runAPI } = await import("./core/api.js");
if (isCluster) {
await initCluster();
}
if (env.envFile) {
setupEnvWatcher();
}
runAPI(express, app, __dirname, cluster.isPrimary);
} else {
console.log(
Red("API_URL env variable is missing, cobalt api can't start.")
)
}

41
api/src/config.js Normal file
View File

@@ -0,0 +1,41 @@
import { getVersion } from "@imput/version-info";
import { loadEnvs, validateEnvs } from "./core/env.js";
const version = await getVersion();
const canonicalEnv = Object.freeze(structuredClone(process.env));
const env = loadEnvs();
const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36";
const cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`;
export const setTunnelPort = (port) => env.tunnelPort = port;
export const isCluster = env.instanceCount > 1;
export const updateEnv = (newEnv) => {
const changes = [];
// tunnelPort is special and needs to get carried over here
newEnv.tunnelPort = env.tunnelPort;
for (const key in env) {
if (key === 'subscribe') {
continue;
}
if (String(env[key]) !== String(newEnv[key])) {
changes.push(key);
}
env[key] = newEnv[key];
}
return changes;
}
await validateEnvs(env);
export {
env,
canonicalEnv,
genericUserAgent,
cobaltUserAgent,
}

388
api/src/core/api.js Normal file
View File

@@ -0,0 +1,388 @@
import cors from "cors";
import http from "node:http";
import rateLimit from "express-rate-limit";
import { setGlobalDispatcher, EnvHttpProxyAgent } from "undici";
import { getCommit, getBranch, getRemote, getVersion } from "@imput/version-info";
import jwt from "../security/jwt.js";
import stream from "../stream/stream.js";
import match from "../processing/match.js";
import { env } from "../config.js";
import { extract } from "../processing/url.js";
import { Bright, Cyan } from "../misc/console-text.js";
import { hashHmac } from "../security/secrets.js";
import { createStore } from "../store/redis-ratelimit.js";
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
import { verifyTurnstileToken } from "../security/turnstile.js";
import { friendlyServiceName } from "../processing/service-alias.js";
import { verifyStream } from "../stream/manage.js";
import { createResponse, normalizeRequest, getIP } from "../processing/request.js";
import { setupTunnelHandler } from "./itunnel.js";
import * as APIKeys from "../security/api-keys.js";
import * as Cookies from "../processing/cookie/manager.js";
import * as YouTubeSession from "../processing/helpers/youtube-session.js";
const git = {
branch: await getBranch(),
commit: await getCommit(),
remote: await getRemote(),
}
const version = await getVersion();
const acceptRegex = /^application\/json(; charset=utf-8)?$/;
const corsConfig = env.corsWildcard ? {} : {
origin: env.corsURL,
optionsSuccessStatus: 200
}
const fail = (res, code, context) => {
const { status, body } = createResponse("error", { code, context });
res.status(status).json(body);
}
export const runAPI = async (express, app, __dirname, isPrimary = true) => {
const startTime = new Date();
const startTimestamp = startTime.getTime();
const getServerInfo = () => {
return JSON.stringify({
cobalt: {
version: version,
url: env.apiURL,
startTime: `${startTimestamp}`,
turnstileSitekey: env.sessionEnabled ? env.turnstileSitekey : undefined,
services: [...env.enabledServices].map(e => {
return friendlyServiceName(e);
}),
},
git,
});
}
const serverInfo = getServerInfo();
const handleRateExceeded = (_, res) => {
const { body } = createResponse("error", {
code: "error.api.rate_exceeded",
context: {
limit: env.rateLimitWindow
}
});
return res.status(429).json(body);
};
const keyGenerator = (req) => hashHmac(getIP(req), 'rate').toString('base64url');
const sessionLimiter = rateLimit({
windowMs: env.sessionRateLimitWindow * 1000,
limit: env.sessionRateLimit,
standardHeaders: 'draft-6',
legacyHeaders: false,
keyGenerator,
store: await createStore('session'),
handler: handleRateExceeded
});
const apiLimiter = rateLimit({
windowMs: env.rateLimitWindow * 1000,
limit: (req) => req.rateLimitMax || env.rateLimitMax,
standardHeaders: 'draft-6',
legacyHeaders: false,
keyGenerator: req => req.rateLimitKey || keyGenerator(req),
store: await createStore('api'),
handler: handleRateExceeded
});
const apiTunnelLimiter = rateLimit({
windowMs: env.tunnelRateLimitWindow * 1000,
limit: env.tunnelRateLimitMax,
standardHeaders: 'draft-6',
legacyHeaders: false,
keyGenerator: req => keyGenerator(req),
store: await createStore('tunnel'),
handler: (_, res) => {
return res.sendStatus(429);
}
});
app.set('trust proxy', ['loopback', 'uniquelocal']);
app.use('/', cors({
methods: ['GET', 'POST'],
exposedHeaders: [
'Ratelimit-Limit',
'Ratelimit-Policy',
'Ratelimit-Remaining',
'Ratelimit-Reset'
],
...corsConfig,
}));
app.post('/', (req, res, next) => {
if (!acceptRegex.test(req.header('Accept'))) {
return fail(res, "error.api.header.accept");
}
if (!acceptRegex.test(req.header('Content-Type'))) {
return fail(res, "error.api.header.content_type");
}
next();
});
app.post('/', (req, res, next) => {
if (!env.apiKeyURL) {
return next();
}
const { success, error } = APIKeys.validateAuthorization(req);
if (!success) {
// We call next() here if either if:
// a) we have user sessions enabled, meaning the request
// will still need a Bearer token to not be rejected, or
// b) we do not require the user to be authenticated, and
// so they can just make the request with the regular
// rate limit configuration;
// otherwise, we reject the request.
if (
(env.sessionEnabled || !env.authRequired)
&& ['missing', 'not_api_key'].includes(error)
) {
return next();
}
return fail(res, `error.api.auth.key.${error}`);
}
req.authType = "key";
return next();
});
app.post('/', (req, res, next) => {
if (!env.sessionEnabled || req.rateLimitKey) {
return next();
}
try {
const authorization = req.header("Authorization");
if (!authorization) {
return fail(res, "error.api.auth.jwt.missing");
}
if (authorization.length >= 256) {
return fail(res, "error.api.auth.jwt.invalid");
}
const [ type, token, ...rest ] = authorization.split(" ");
if (!token || type.toLowerCase() !== 'bearer' || rest.length) {
return fail(res, "error.api.auth.jwt.invalid");
}
if (!jwt.verify(token, getIP(req, 32))) {
return fail(res, "error.api.auth.jwt.invalid");
}
req.rateLimitKey = hashHmac(token, 'rate');
req.authType = "session";
} catch {
return fail(res, "error.api.generic");
}
next();
});
app.post('/', apiLimiter);
app.use('/', express.json({ limit: 1024 }));
app.use('/', (err, _, res, next) => {
if (err) {
const { status, body } = createResponse("error", {
code: "error.api.invalid_body",
});
return res.status(status).json(body);
}
next();
});
app.post("/session", sessionLimiter, async (req, res) => {
if (!env.sessionEnabled) {
return fail(res, "error.api.auth.not_configured")
}
const turnstileResponse = req.header("cf-turnstile-response");
if (!turnstileResponse) {
return fail(res, "error.api.auth.turnstile.missing");
}
const turnstileResult = await verifyTurnstileToken(
turnstileResponse,
req.ip
);
if (!turnstileResult) {
return fail(res, "error.api.auth.turnstile.invalid");
}
try {
res.json(jwt.generate(getIP(req, 32)));
} catch {
return fail(res, "error.api.generic");
}
});
app.post('/', async (req, res) => {
const request = req.body;
if (!request.url) {
return fail(res, "error.api.link.missing");
}
const { success, data: normalizedRequest } = await normalizeRequest(request);
if (!success) {
return fail(res, "error.api.invalid_body");
}
const parsed = extract(
normalizedRequest.url,
APIKeys.getAllowedServices(req.rateLimitKey),
);
if (!parsed) {
return fail(res, "error.api.link.invalid");
}
if ("error" in parsed) {
let context;
if (parsed?.context) {
context = parsed.context;
}
return fail(res, `error.api.${parsed.error}`, context);
}
try {
const result = await match({
host: parsed.host,
patternMatch: parsed.patternMatch,
params: normalizedRequest,
authType: req.authType ?? "none",
});
res.status(result.status).json(result.body);
} catch {
fail(res, "error.api.generic");
}
});
app.use('/tunnel', cors({
methods: ['GET'],
exposedHeaders: [
'Estimated-Content-Length',
'Content-Disposition'
],
...corsConfig,
}));
app.get('/tunnel', apiTunnelLimiter, async (req, res) => {
const id = String(req.query.id);
const exp = String(req.query.exp);
const sig = String(req.query.sig);
const sec = String(req.query.sec);
const iv = String(req.query.iv);
const checkQueries = id && exp && sig && sec && iv;
const checkBaseLength = id.length === 21 && exp.length === 13;
const checkSafeLength = sig.length === 43 && sec.length === 43 && iv.length === 22;
if (!checkQueries || !checkBaseLength || !checkSafeLength) {
return res.status(400).end();
}
if (req.query.p) {
return res.status(200).end();
}
const streamInfo = await verifyStream(id, sig, exp, sec, iv);
if (!streamInfo?.service) {
return res.status(streamInfo.status).end();
}
if (streamInfo.type === 'proxy') {
streamInfo.range = req.headers['range'];
}
return stream(res, streamInfo);
});
app.get('/', (_, res) => {
res.type('json');
res.status(200).send(env.envFile ? getServerInfo() : serverInfo);
})
app.get('/favicon.ico', (req, res) => {
res.status(404).end();
})
app.get('/*', (req, res) => {
res.redirect('/');
})
// handle all express errors
app.use((_, __, res, ___) => {
return fail(res, "error.api.generic");
})
randomizeCiphers();
setInterval(randomizeCiphers, 1000 * 60 * 30); // shuffle ciphers every 30 minutes
env.subscribe(['externalProxy', 'httpProxyValues'], () => {
// TODO: remove env.externalProxy in a future version
const options = {};
if (env.externalProxy) {
options.httpProxy = env.externalProxy;
}
setGlobalDispatcher(
new EnvHttpProxyAgent(options)
);
});
http.createServer(app).listen({
port: env.apiPort,
host: env.listenAddress,
reusePort: env.instanceCount > 1 || undefined
}, () => {
if (isPrimary) {
console.log(`\n` +
Bright(Cyan("cobalt ")) + Bright("API ^ω^") + "\n" +
"~~~~~~\n" +
Bright("version: ") + version + "\n" +
Bright("commit: ") + git.commit + "\n" +
Bright("branch: ") + git.branch + "\n" +
Bright("remote: ") + git.remote + "\n" +
Bright("start time: ") + startTime.toUTCString() + "\n" +
"~~~~~~\n" +
Bright("url: ") + Bright(Cyan(env.apiURL)) + "\n" +
Bright("port: ") + env.apiPort + "\n"
);
}
if (env.apiKeyURL) {
APIKeys.setup(env.apiKeyURL);
}
if (env.cookiePath) {
Cookies.setup(env.cookiePath);
}
if (env.ytSessionServer) {
YouTubeSession.setup();
}
});
setupTunnelHandler();
}

289
api/src/core/env.js Normal file
View File

@@ -0,0 +1,289 @@
import { Constants } from "youtubei.js";
import { services } from "../processing/service-config.js";
import { updateEnv, canonicalEnv, env as currentEnv } from "../config.js";
import { FileWatcher } from "../misc/file-watcher.js";
import { isURL } from "../misc/utils.js";
import * as cluster from "../misc/cluster.js";
import { Green, Yellow } from "../misc/console-text.js";
const forceLocalProcessingOptions = ["never", "session", "always"];
const youtubeHlsOptions = ["never", "key", "always"];
const httpProxyVariables = ["NO_PROXY", "HTTP_PROXY", "HTTPS_PROXY"].flatMap(
k => [ k, k.toLowerCase() ]
);
const changeCallbacks = {};
const onEnvChanged = (changes) => {
for (const key of changes) {
if (changeCallbacks[key]) {
changeCallbacks[key].map(fn => {
try { fn() } catch {}
});
}
}
}
const subscribe = (keys, fn) => {
keys = [keys].flat();
for (const key of keys) {
if (key in currentEnv && key !== 'subscribe') {
changeCallbacks[key] ??= [];
changeCallbacks[key].push(fn);
fn();
} else throw `invalid env key ${key}`;
}
}
export const loadEnvs = (env = process.env) => {
const allServices = new Set(Object.keys(services));
const disabledServices = env.DISABLED_SERVICES?.split(',') || [];
const enabledServices = new Set(Object.keys(services).filter(e => {
if (!disabledServices.includes(e)) {
return e;
}
}));
// we need to copy the proxy envs (HTTP_PROXY, HTTPS_PROXY)
// back into process.env, so that EnvHttpProxyAgent can pick
// them up later
for (const key of httpProxyVariables) {
const value = env[key] ?? canonicalEnv[key];
if (value !== undefined) {
process.env[key] = env[key];
} else {
delete process.env[key];
}
}
return {
apiURL: env.API_URL || '',
apiPort: env.API_PORT || 9000,
tunnelPort: env.API_PORT || 9000,
listenAddress: env.API_LISTEN_ADDRESS,
freebindCIDR: process.platform === 'linux' && env.FREEBIND_CIDR,
corsWildcard: env.CORS_WILDCARD !== '0',
corsURL: env.CORS_URL,
cookiePath: env.COOKIE_PATH,
rateLimitWindow: (env.RATELIMIT_WINDOW && parseInt(env.RATELIMIT_WINDOW)) || 60,
rateLimitMax: (env.RATELIMIT_MAX && parseInt(env.RATELIMIT_MAX)) || 20,
tunnelRateLimitWindow: (env.TUNNEL_RATELIMIT_WINDOW && parseInt(env.TUNNEL_RATELIMIT_WINDOW)) || 60,
tunnelRateLimitMax: (env.TUNNEL_RATELIMIT_MAX && parseInt(env.TUNNEL_RATELIMIT_MAX)) || 40,
sessionRateLimitWindow: (env.SESSION_RATELIMIT_WINDOW && parseInt(env.SESSION_RATELIMIT_WINDOW)) || 60,
sessionRateLimit:
// backwards compatibility with SESSION_RATELIMIT
// till next major due to an error in docs
(env.SESSION_RATELIMIT_MAX && parseInt(env.SESSION_RATELIMIT_MAX))
|| (env.SESSION_RATELIMIT && parseInt(env.SESSION_RATELIMIT))
|| 10,
durationLimit: (env.DURATION_LIMIT && parseInt(env.DURATION_LIMIT)) || 10800,
streamLifespan: (env.TUNNEL_LIFESPAN && parseInt(env.TUNNEL_LIFESPAN)) || 90,
processingPriority: process.platform !== 'win32'
&& env.PROCESSING_PRIORITY
&& parseInt(env.PROCESSING_PRIORITY),
externalProxy: env.API_EXTERNAL_PROXY,
// used only for comparing against old values when envs are being updated
httpProxyValues: httpProxyVariables.map(k => String(env[k])).join(''),
turnstileSitekey: env.TURNSTILE_SITEKEY,
turnstileSecret: env.TURNSTILE_SECRET,
jwtSecret: env.JWT_SECRET,
jwtLifetime: env.JWT_EXPIRY || 120,
sessionEnabled: env.TURNSTILE_SITEKEY
&& env.TURNSTILE_SECRET
&& env.JWT_SECRET,
apiKeyURL: env.API_KEY_URL && new URL(env.API_KEY_URL),
authRequired: env.API_AUTH_REQUIRED === '1',
redisURL: env.API_REDIS_URL,
instanceCount: (env.API_INSTANCE_COUNT && parseInt(env.API_INSTANCE_COUNT)) || 1,
keyReloadInterval: 900,
allServices,
enabledServices,
customInnertubeClient: env.CUSTOM_INNERTUBE_CLIENT,
ytSessionServer: env.YOUTUBE_SESSION_SERVER,
ytSessionReloadInterval: 300,
ytSessionInnertubeClient: env.YOUTUBE_SESSION_INNERTUBE_CLIENT,
ytAllowBetterAudio: env.YOUTUBE_ALLOW_BETTER_AUDIO !== "0",
// "never" | "session" | "always"
forceLocalProcessing: env.FORCE_LOCAL_PROCESSING ?? "never",
// "never" | "key" | "always"
enableDeprecatedYoutubeHls: env.ENABLE_DEPRECATED_YOUTUBE_HLS ?? "never",
envFile: env.API_ENV_FILE,
envRemoteReloadInterval: 300,
subscribe,
};
}
let loggedProxyWarning = false;
export const validateEnvs = async (env) => {
if (env.sessionEnabled && env.jwtSecret.length < 16) {
throw new Error("JWT_SECRET env is too short (must be at least 16 characters long)");
}
if (env.instanceCount > 1 && !env.redisURL) {
throw new Error("API_REDIS_URL is required when API_INSTANCE_COUNT is >= 2");
} else if (env.instanceCount > 1 && !await cluster.supportsReusePort()) {
console.error('API_INSTANCE_COUNT is not supported in your environment. to use this env, your node.js');
console.error('version must be >= 23.1.0, and you must be running a recent enough version of linux');
console.error('(or other OS that supports it). for more info, see `reusePort` option on');
console.error('https://nodejs.org/api/net.html#serverlistenoptions-callback');
throw new Error('SO_REUSEPORT is not supported');
}
if (env.customInnertubeClient && !Constants.SUPPORTED_CLIENTS.includes(env.customInnertubeClient)) {
console.error("CUSTOM_INNERTUBE_CLIENT is invalid. Provided client is not supported.");
console.error(`Supported clients are: ${Constants.SUPPORTED_CLIENTS.join(', ')}\n`);
throw new Error("Invalid CUSTOM_INNERTUBE_CLIENT");
}
if (env.forceLocalProcessing && !forceLocalProcessingOptions.includes(env.forceLocalProcessing)) {
console.error("FORCE_LOCAL_PROCESSING is invalid.");
console.error(`Supported options are are: ${forceLocalProcessingOptions.join(', ')}\n`);
throw new Error("Invalid FORCE_LOCAL_PROCESSING");
}
if (env.enableDeprecatedYoutubeHls && !youtubeHlsOptions.includes(env.enableDeprecatedYoutubeHls)) {
console.error("ENABLE_DEPRECATED_YOUTUBE_HLS is invalid.");
console.error(`Supported options are are: ${youtubeHlsOptions.join(', ')}\n`);
throw new Error("Invalid ENABLE_DEPRECATED_YOUTUBE_HLS");
}
if (env.externalProxy && env.freebindCIDR) {
throw new Error('freebind is not available when external proxy is enabled')
}
if (env.externalProxy && !loggedProxyWarning) {
console.error('API_EXTERNAL_PROXY is deprecated and will be removed in a future release.');
console.error('Use HTTP_PROXY or HTTPS_PROXY instead.');
console.error('You can read more about the new proxy variables in docs/api-env-variables.md\n');
// prevent the warning from being printed on every env validation
loggedProxyWarning = true;
}
return env;
}
const reloadEnvs = async (contents) => {
const newEnvs = {};
const resolvedContents = await contents;
for (let line of resolvedContents.split('\n')) {
line = line.trim();
if (line === '') {
continue;
}
let [ key, value ] = line.split(/=(.+)?/);
if (key) {
if (value.match(/^['"]/) && value.match(/['"]$/)) {
value = JSON.parse(value);
}
newEnvs[key] = value || '';
}
}
const candidate = {
...canonicalEnv,
...newEnvs,
};
const parsed = await validateEnvs(
loadEnvs(candidate)
);
cluster.broadcast({ env_update: resolvedContents });
return updateEnv(parsed);
}
const wrapReload = (contents) => {
reloadEnvs(contents)
.then(changes => {
if (changes.length === 0) {
return;
}
onEnvChanged(changes);
console.log(`${Green('[✓]')} envs reloaded successfully!`);
for (const key of changes) {
const value = currentEnv[key];
const isSecret = key.toLowerCase().includes('apikey')
|| key.toLowerCase().includes('secret')
|| key === 'httpProxyValues';
if (!value) {
console.log(` removed: ${key}`);
} else {
console.log(` changed: ${key} -> ${isSecret ? '***' : value}`);
}
}
})
.catch((e) => {
console.error(`${Yellow('[!]')} Failed reloading environment variables at ${new Date().toISOString()}.`);
console.error('Error:', e);
});
}
let watcher;
const setupWatcherFromFile = (path) => {
const load = () => wrapReload(watcher.read());
if (isURL(path)) {
watcher = FileWatcher.fromFileProtocol(path);
} else {
watcher = new FileWatcher({ path });
}
watcher.on('file-updated', load);
load();
}
const setupWatcherFromFetch = (url) => {
const load = () => wrapReload(fetch(url).then(r => r.text()));
setInterval(load, currentEnv.envRemoteReloadInterval);
load();
}
export const setupEnvWatcher = () => {
if (cluster.isPrimary) {
const envFile = currentEnv.envFile;
const isFile = !isURL(envFile)
|| new URL(envFile).protocol === 'file:';
if (isFile) {
setupWatcherFromFile(envFile);
} else {
setupWatcherFromFetch(envFile);
}
} else if (cluster.isWorker) {
process.on('message', (message) => {
if ('env_update' in message) {
reloadEnvs(message.env_update);
}
});
}
}

61
api/src/core/itunnel.js Normal file
View 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);
});
}

72
api/src/misc/cluster.js Normal file
View File

@@ -0,0 +1,72 @@
import cluster from "node:cluster";
import net from "node:net";
import { syncSecrets } from "../security/secrets.js";
import { env, isCluster } from "../config.js";
export { isPrimary, isWorker } from "node:cluster";
export const supportsReusePort = async () => {
try {
await new Promise((resolve, reject) => {
const server = net.createServer().listen({ port: 0, reusePort: true });
server.on('listening', () => server.close(resolve));
server.on('error', (err) => (server.close(), reject(err)));
});
const [major, minor] = process.versions.node.split('.').map(Number);
return major > 23 || (major === 23 && minor >= 1);
} catch {
return false;
}
}
export const initCluster = async () => {
if (cluster.isPrimary) {
for (let i = 1; i < env.instanceCount; ++i) {
cluster.fork();
}
}
await syncSecrets();
}
export const broadcast = (message) => {
if (!isCluster || !cluster.isPrimary || !cluster.workers) {
return;
}
for (const worker of Object.values(cluster.workers)) {
worker.send(message);
}
}
export const send = (message) => {
if (!isCluster) {
return;
}
if (cluster.isPrimary) {
return broadcast(message);
} else {
return process.send(message);
}
}
export const waitFor = (key) => {
return new Promise(resolve => {
const listener = (message) => {
if (key in message) {
process.off('message', listener);
return resolve(message);
}
}
process.on('message', listener);
});
}
export const mainOnMessage = (cb) => {
for (const worker of Object.values(cluster.workers)) {
worker.on('message', cb);
}
}

View File

@@ -0,0 +1,36 @@
const ANSI = {
RESET: "\x1b[0m",
BRIGHT: "\x1b[1m",
RED: "\x1b[31m",
GREEN: "\x1b[32m",
CYAN: "\x1b[36m",
YELLOW: "\x1b[93m"
}
function wrap(color, text) {
if (!ANSI[color.toUpperCase()]) {
throw "invalid color";
}
return ANSI[color.toUpperCase()] + text + ANSI.RESET;
}
export function Bright(text) {
return wrap('bright', text);
}
export function Red(text) {
return wrap('red', text);
}
export function Green(text) {
return wrap('green', text);
}
export function Cyan(text) {
return wrap('cyan', text);
}
export function Yellow(text) {
return wrap('yellow', text);
}

View File

@@ -1,15 +1,7 @@
import { createHmac, createCipheriv, createDecipheriv, randomBytes } from "crypto";
import { createCipheriv, createDecipheriv } from "crypto";
const algorithm = "aes256";
export function generateSalt() {
return randomBytes(64).toString('hex');
}
export function generateHmac(str, salt) {
return createHmac("sha256", salt).update(str).digest("base64url");
}
export function encryptStream(plaintext, iv, secret) {
const buff = Buffer.from(JSON.stringify(plaintext));
const key = Buffer.from(secret, "base64url");

View File

@@ -0,0 +1,43 @@
import { EventEmitter } from 'node:events';
import * as fs from 'node:fs/promises';
export class FileWatcher extends EventEmitter {
#path;
#hasWatcher = false;
#lastChange = new Date().getTime();
constructor({ path, ...rest }) {
super(rest);
this.#path = path;
}
async #setupWatcher() {
if (this.#hasWatcher)
return;
this.#hasWatcher = true;
const watcher = fs.watch(this.#path);
for await (const _ of watcher) {
if (new Date() - this.#lastChange > 50) {
this.emit('file-updated');
this.#lastChange = new Date().getTime();
}
}
}
read() {
this.#setupWatcher();
return fs.readFile(this.#path, 'utf8');
}
static fromFileProtocol(url_) {
const url = new URL(url_);
if (url.protocol !== 'file:') {
return;
}
const pathname = url.pathname === '/' ? '' : url.pathname;
const file_path = decodeURIComponent(url.host + pathname);
return new this({ path: file_path });
}
}

View File

@@ -0,0 +1,54 @@
// converted from this file https://www.loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt
const iso639_1to2 = {
'aa': 'aar', 'ab': 'abk', 'af': 'afr', 'ak': 'aka', 'sq': 'sqi',
'am': 'amh', 'ar': 'ara', 'an': 'arg', 'hy': 'hye', 'as': 'asm',
'av': 'ava', 'ae': 'ave', 'ay': 'aym', 'az': 'aze', 'ba': 'bak',
'bm': 'bam', 'eu': 'eus', 'be': 'bel', 'bn': 'ben', 'bi': 'bis',
'bs': 'bos', 'br': 'bre', 'bg': 'bul', 'my': 'mya', 'ca': 'cat',
'ch': 'cha', 'ce': 'che', 'zh': 'zho', 'cu': 'chu', 'cv': 'chv',
'kw': 'cor', 'co': 'cos', 'cr': 'cre', 'cs': 'ces', 'da': 'dan',
'dv': 'div', 'nl': 'nld', 'dz': 'dzo', 'en': 'eng', 'eo': 'epo',
'et': 'est', 'ee': 'ewe', 'fo': 'fao', 'fj': 'fij', 'fi': 'fin',
'fr': 'fra', 'fy': 'fry', 'ff': 'ful', 'ka': 'kat', 'de': 'deu',
'gd': 'gla', 'ga': 'gle', 'gl': 'glg', 'gv': 'glv', 'el': 'ell',
'gn': 'grn', 'gu': 'guj', 'ht': 'hat', 'ha': 'hau', 'he': 'heb',
'hz': 'her', 'hi': 'hin', 'ho': 'hmo', 'hr': 'hrv', 'hu': 'hun',
'ig': 'ibo', 'is': 'isl', 'io': 'ido', 'ii': 'iii', 'iu': 'iku',
'ie': 'ile', 'ia': 'ina', 'id': 'ind', 'ik': 'ipk', 'it': 'ita',
'jv': 'jav', 'ja': 'jpn', 'kl': 'kal', 'kn': 'kan', 'ks': 'kas',
'kr': 'kau', 'kk': 'kaz', 'km': 'khm', 'ki': 'kik', 'rw': 'kin',
'ky': 'kir', 'kv': 'kom', 'kg': 'kon', 'ko': 'kor', 'kj': 'kua',
'ku': 'kur', 'lo': 'lao', 'la': 'lat', 'lv': 'lav', 'li': 'lim',
'ln': 'lin', 'lt': 'lit', 'lb': 'ltz', 'lu': 'lub', 'lg': 'lug',
'mk': 'mkd', 'mh': 'mah', 'ml': 'mal', 'mi': 'mri', 'mr': 'mar',
'ms': 'msa', 'mg': 'mlg', 'mt': 'mlt', 'mn': 'mon', 'na': 'nau',
'nv': 'nav', 'nr': 'nbl', 'nd': 'nde', 'ng': 'ndo', 'ne': 'nep',
'nn': 'nno', 'nb': 'nob', 'no': 'nor', 'ny': 'nya', 'oc': 'oci',
'oj': 'oji', 'or': 'ori', 'om': 'orm', 'os': 'oss', 'pa': 'pan',
'fa': 'fas', 'pi': 'pli', 'pl': 'pol', 'pt': 'por', 'ps': 'pus',
'qu': 'que', 'rm': 'roh', 'ro': 'ron', 'rn': 'run', 'ru': 'rus',
'sg': 'sag', 'sa': 'san', 'si': 'sin', 'sk': 'slk', 'sl': 'slv',
'se': 'sme', 'sm': 'smo', 'sn': 'sna', 'sd': 'snd', 'so': 'som',
'st': 'sot', 'es': 'spa', 'sc': 'srd', 'sr': 'srp', 'ss': 'ssw',
'su': 'sun', 'sw': 'swa', 'sv': 'swe', 'ty': 'tah', 'ta': 'tam',
'tt': 'tat', 'te': 'tel', 'tg': 'tgk', 'tl': 'tgl', 'th': 'tha',
'bo': 'bod', 'ti': 'tir', 'to': 'ton', 'tn': 'tsn', 'ts': 'tso',
'tk': 'tuk', 'tr': 'tur', 'tw': 'twi', 'ug': 'uig', 'uk': 'ukr',
'ur': 'urd', 'uz': 'uzb', 've': 'ven', 'vi': 'vie', 'vo': 'vol',
'cy': 'cym', 'wa': 'wln', 'wo': 'wol', 'xh': 'xho', 'yi': 'yid',
'yo': 'yor', 'za': 'zha', 'zu': 'zul',
}
const iso639_2to1 = Object.fromEntries(
Object.entries(iso639_1to2).map(([k, v]) => [v, k])
);
const maps = {
2: iso639_1to2,
3: iso639_2to1,
}
export const convertLanguageCode = (code) => {
code = code?.split("-")[0]?.split("_")[0] || "";
return maps[code.length]?.[code.toLowerCase()] || null;
}

View File

@@ -0,0 +1,20 @@
import * as fs from "fs";
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(
dirname(fileURLToPath(import.meta.url)),
'../../'
);
export function loadFile(path) {
return fs.readFileSync(join(root, path), 'utf-8')
}
export function loadJSON(path) {
try {
return JSON.parse(loadFile(path))
} catch {
return false
}
}

View File

@@ -1,10 +1,10 @@
import { normalizeRequest } from "../modules/processing/request.js";
import match from "./processing/match.js";
import { extract } from "./processing/url.js";
import { normalizeRequest } from "../processing/request.js";
import match from "../processing/match.js";
import { extract } from "../processing/url.js";
export async function runTest(url, params, expect) {
const normalized = normalizeRequest({ url, ...params });
if (!normalized) {
const { success, data: normalized } = await normalizeRequest({ url, ...params });
if (!success) {
throw "invalid request";
}
@@ -13,14 +13,25 @@ export async function runTest(url, params, expect) {
throw `invalid url: ${normalized.url}`;
}
const result = await match(
parsed.host, parsed.patternMatch, "en", normalized
);
const result = await match({
host: parsed.host,
patternMatch: parsed.patternMatch,
params: normalized,
});
let error = [];
if (expect.status !== result.body.status) {
const detail = `${expect.status} (expected) != ${result.body.status} (actual)`;
error.push(`status mismatch: ${detail}`);
if (result.body.status === 'error') {
error.push(`error code: ${result.body?.error?.code}`);
}
}
if (expect.errorCode && expect.errorCode !== result.body?.error?.code) {
const detail = `${expect.errorCode} (expected) != ${result.body.error.code} (actual)`
error.push(`error mismatch: ${detail}`);
}
if (expect.code !== result.status) {
@@ -36,7 +47,7 @@ export async function runTest(url, params, expect) {
throw error.join('\n');
}
if (result.body.status === 'stream') {
if (result.body.status === 'tunnel') {
// TODO: stream testing
}
}
}

79
api/src/misc/utils.js Normal file
View File

@@ -0,0 +1,79 @@
import { request } from "undici";
const redirectStatuses = new Set([301, 302, 303, 307, 308]);
export async function getRedirectingURL(url, dispatcher, headers) {
const params = {
dispatcher,
method: 'HEAD',
headers,
redirect: 'manual'
};
const getParams = {
...params,
method: 'GET',
};
const callback = (r) => {
if (redirectStatuses.has(r.statusCode) && r.headers['location']) {
return r.headers['location'];
}
}
/*
try request() with HEAD & GET,
then do the same with fetch
(fetch is required for shortened reddit links)
*/
let location = await request(url, params)
.then(callback).catch(() => null);
location ??= await request(url, getParams)
.then(callback).catch(() => null);
location ??= await fetch(url, params)
.then(callback).catch(() => null);
location ??= await fetch(url, getParams)
.then(callback).catch(() => null);
return location;
}
export function merge(a, b) {
for (const k of Object.keys(b)) {
if (Array.isArray(b[k])) {
a[k] = [...(a[k] ?? []), ...b[k]];
} else if (typeof b[k] === 'object') {
a[k] = merge(a[k], b[k]);
} else {
a[k] = b[k];
}
}
return a;
}
export function splitFilenameExtension(filename) {
const parts = filename.split('.');
const ext = parts.pop();
if (!parts.length) {
return [ ext, "" ]
} else {
return [ parts.join('.'), ext ]
}
}
export function zip(a, b) {
return a.map((value, i) => [ value, b[i] ]);
}
export function isURL(input) {
try {
new URL(input);
return true;
} catch {
return false;
}
}

View File

@@ -4,16 +4,24 @@ export default class Cookie {
constructor(input) {
assert(typeof input === 'object');
this._values = {};
this.set(input)
for (const [ k, v ] of Object.entries(input))
this.set(k, v);
}
set(values) {
Object.entries(values).forEach(
([ key, value ]) => this._values[key] = value
)
set(key, value) {
const old = this._values[key];
if (old === value)
return false;
this._values[key] = value;
return true;
}
unset(keys) {
for (const key of keys) delete this._values[key]
}
static fromString(str) {
const obj = {};
@@ -25,12 +33,15 @@ export default class Cookie {
return new Cookie(obj)
}
toString() {
return Object.entries(this._values).map(([ name, value ]) => `${name}=${value}`).join('; ')
}
toJSON() {
return this.toString()
}
values() {
return Object.freeze({ ...this._values })
}

View File

@@ -0,0 +1,157 @@
import Cookie from './cookie.js';
import { readFile, writeFile } from 'fs/promises';
import { Red, Green, Yellow } from '../../misc/console-text.js';
import { parse as parseSetCookie, splitCookiesString } from 'set-cookie-parser';
import * as cluster from '../../misc/cluster.js';
import { isCluster } from '../../config.js';
const WRITE_INTERVAL = 60000;
const VALID_SERVICES = new Set([
'instagram',
'instagram_bearer',
'reddit',
'twitter',
'youtube',
'vimeo_bearer',
]);
const invalidCookies = {};
let cookies = {}, dirty = false, intervalId;
function writeChanges(cookiePath) {
if (!dirty) return;
dirty = false;
const cookieData = JSON.stringify({ ...cookies, ...invalidCookies }, null, 4);
writeFile(cookiePath, cookieData).catch((e) => {
console.warn(`${Yellow('[!]')} failed writing updated cookies to storage`);
console.warn(e);
clearInterval(intervalId);
intervalId = null;
})
}
const setupMain = async (cookiePath) => {
try {
cookies = await readFile(cookiePath, 'utf8');
cookies = JSON.parse(cookies);
for (const serviceName in cookies) {
if (!VALID_SERVICES.has(serviceName)) {
console.warn(`${Yellow('[!]')} ignoring unknown service in cookie file: ${serviceName}`);
} else if (!Array.isArray(cookies[serviceName])) {
console.warn(`${Yellow('[!]')} ${serviceName} in cookies file is not an array, ignoring it`);
} else if (cookies[serviceName].some(c => typeof c !== 'string')) {
console.warn(`${Yellow('[!]')} some cookie for ${serviceName} contains non-string value in cookies file`);
} else continue;
invalidCookies[serviceName] = cookies[serviceName];
delete cookies[serviceName];
}
if (!intervalId) {
intervalId = setInterval(() => writeChanges(cookiePath), WRITE_INTERVAL);
}
cluster.broadcast({ cookies });
console.log(`${Green('[✓]')} cookies loaded successfully!`);
} catch (e) {
console.error(`${Yellow('[!]')} failed to load cookies.`);
console.error('error:', e);
}
}
const setupWorker = async () => {
cookies = (await cluster.waitFor('cookies')).cookies;
}
export const loadFromFile = async (path) => {
if (cluster.isPrimary) {
await setupMain(path);
} else if (cluster.isWorker) {
await setupWorker();
}
dirty = false;
}
export const setup = async (path) => {
await loadFromFile(path);
if (isCluster) {
const messageHandler = (message) => {
if ('cookieUpdate' in message) {
const { cookieUpdate } = message;
if (cluster.isPrimary) {
dirty = true;
cluster.broadcast({ cookieUpdate });
}
const { service, idx, cookie } = cookieUpdate;
cookies[service][idx] = cookie;
}
}
if (cluster.isPrimary) {
cluster.mainOnMessage(messageHandler);
} else {
process.on('message', messageHandler);
}
}
}
export function getCookie(service) {
if (!VALID_SERVICES.has(service)) {
console.error(
`${Red('[!]')} ${service} not in allowed services list for cookies.`
+ ' if adding a new cookie type, include it there.'
);
return;
}
if (!cookies[service] || !cookies[service].length) return;
const idx = Math.floor(Math.random() * cookies[service].length);
const cookie = cookies[service][idx];
if (typeof cookie === 'string') {
cookies[service][idx] = Cookie.fromString(cookie);
}
cookies[service][idx].meta = { service, idx };
return cookies[service][idx];
}
export function updateCookieValues(cookie, values) {
let changed = false;
for (const [ key, value ] of Object.entries(values)) {
changed = cookie.set(key, value) || changed;
}
if (changed && cookie.meta) {
dirty = true;
if (isCluster) {
const message = { cookieUpdate: { ...cookie.meta, cookie } };
cluster.send(message);
}
}
return changed;
}
export function updateCookie(cookie, headers) {
if (!cookie) return;
const parsed = parseSetCookie(
splitCookiesString(headers.get('set-cookie')),
{ decodeValues: false }
), values = {}
cookie.unset(parsed.filter(c => c.expires < new Date()).map(c => c.name));
parsed.filter(c => !c.expires || c.expires > new Date()).forEach(c => values[c.name] = c.value);
updateCookieValues(cookie, values);
}

View File

@@ -0,0 +1,85 @@
// characters that are disallowed on windows:
// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions
const characterMap = {
'<': '',
'>': '',
':': '',
'"': '',
'/': '',
'\\': '',
'|': '',
'?': '',
'*': ''
};
export const sanitizeString = (string) => {
// remove any potential control characters the string might contain
string = string.replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
for (const [ char, replacement ] of Object.entries(characterMap)) {
string = string.replaceAll(char, replacement);
}
return string;
}
export default (f, style, isAudioOnly, isAudioMuted) => {
let filename = '';
let infoBase = [f.service, f.id];
let classicTags = [...infoBase];
let basicTags = [];
let title = sanitizeString(f.title);
if (f.author) {
title += ` - ${sanitizeString(f.author)}`;
}
if (f.resolution) {
classicTags.push(f.resolution);
}
if (f.qualityLabel) {
basicTags.push(f.qualityLabel);
}
if (f.youtubeFormat) {
classicTags.push(f.youtubeFormat);
basicTags.push(f.youtubeFormat);
}
if (isAudioMuted) {
classicTags.push("mute");
basicTags.push("mute");
} else if (f.youtubeDubName) {
classicTags.push(f.youtubeDubName);
basicTags.push(f.youtubeDubName);
}
switch (style) {
default:
case "classic":
if (isAudioOnly) {
if (f.youtubeDubName) {
infoBase.push(f.youtubeDubName);
}
return `${infoBase.join("_")}_audio`;
}
filename = classicTags.join("_");
break;
case "basic":
if (isAudioOnly) return title;
filename = `${title} (${basicTags.join(", ")})`;
break;
case "pretty":
if (isAudioOnly) return `${title} (${infoBase[0]})`;
filename = `${title} (${[...basicTags, infoBase[0]].join(", ")})`;
break;
case "nerdy":
if (isAudioOnly) return `${title} (${infoBase.join(", ")})`;
filename = `${title} (${basicTags.concat(infoBase).join(", ")})`;
break;
}
return `${filename}.${f.extension}`;
}

View File

@@ -0,0 +1,85 @@
import * as cluster from "../../misc/cluster.js";
import { Agent } from "undici";
import { env } from "../../config.js";
import { Green, Yellow } from "../../misc/console-text.js";
const defaultAgent = new Agent();
let session;
const validateSession = (sessionResponse) => {
sessionResponse.visitor_data ??= sessionResponse.contentBinding;
sessionResponse.potoken ??= sessionResponse.poToken;
sessionResponse.updated ??= new Date().getTime();
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 = "/get_pot";
const newSession = await fetch(
sessionServerUrl,
{ method: 'POST', dispatcher: defaultAgent }
).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);
}
});
}
}

View File

@@ -0,0 +1,283 @@
import createFilename from "./create-filename.js";
import { createResponse } from "./request.js";
import { audioIgnore } from "./service-config.js";
import { createStream } from "../stream/manage.js";
import { splitFilenameExtension } from "../misc/utils.js";
import { convertLanguageCode } from "../misc/language-codes.js";
const extraProcessingTypes = new Set(["merge", "remux", "mute", "audio", "gif"]);
export default function({
r,
host,
audioFormat,
isAudioOnly,
isAudioMuted,
disableMetadata,
filenameStyle,
convertGif,
requestIP,
audioBitrate,
alwaysProxy,
localProcessing,
}) {
let action,
responseType = "tunnel",
defaultParams = {
url: r.urls,
headers: r.headers,
service: host,
filename: r.filenameAttributes ?
createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename,
fileMetadata: !disableMetadata ? r.fileMetadata : false,
requestIP,
originalRequest: r.originalRequest,
subtitles: r.subtitles,
cover: !disableMetadata ? r.cover : false,
cropCover: !disableMetadata ? r.cropCover : false,
},
params = {};
if (r.isPhoto) action = "photo";
else if (r.picker) action = "picker"
else if (r.isGif && convertGif) action = "gif";
else if (isAudioOnly) action = "audio";
else if (isAudioMuted) action = "muteVideo";
else if (r.isHLS) action = "hls";
else action = "video";
if (action === "picker" || action === "audio") {
if (!r.filenameAttributes) defaultParams.filename = r.audioFilename;
defaultParams.audioFormat = audioFormat;
}
if (action === "muteVideo" && isAudioMuted && !r.filenameAttributes) {
const [ name, ext ] = splitFilenameExtension(r.filename);
defaultParams.filename = `${name}_mute.${ext}`;
} else if (action === "gif") {
const [ name ] = splitFilenameExtension(r.filename);
defaultParams.filename = `${name}.gif`;
}
switch (action) {
default:
return createResponse("error", {
code: "error.api.fetch.empty"
});
case "photo":
params = { type: "proxy" };
break;
case "gif":
params = { type: "gif" };
break;
case "hls":
params = {
type: Array.isArray(r.urls) ? "merge" : "remux",
isHLS: true,
}
break;
case "muteVideo":
let muteType = "mute";
if (Array.isArray(r.urls) && !r.isHLS) {
muteType = "proxy";
}
params = {
type: muteType,
url: Array.isArray(r.urls) ? r.urls[0] : r.urls,
isHLS: r.isHLS
}
if (host === "reddit" && r.typeId === "redirect") {
responseType = "redirect";
}
break;
case "picker":
responseType = "picker";
switch (host) {
case "instagram":
case "twitter":
case "snapchat":
case "bsky":
case "xiaohongshu":
params = { picker: r.picker };
break;
case "tiktok":
let audioStreamType = "audio";
if (r.bestAudio === "mp3" && audioFormat === "best") {
audioFormat = "mp3";
audioStreamType = "proxy"
}
params = {
picker: r.picker,
url: createStream({
service: "tiktok",
type: audioStreamType,
url: r.urls,
headers: r.headers,
filename: `${r.audioFilename}.${audioFormat}`,
isAudioOnly: true,
audioFormat,
audioBitrate
})
}
break;
}
break;
case "video":
switch (host) {
case "bilibili":
params = { type: "merge" };
break;
case "youtube":
params = { type: r.type };
break;
case "reddit":
responseType = r.typeId;
params = { type: r.type };
break;
case "vimeo":
if (Array.isArray(r.urls)) {
params = { type: "merge" };
} else if (r.subtitles) {
params = { type: "remux" };
} else {
responseType = "redirect";
}
break;
case "twitter":
if (r.type === "remux") {
params = { type: r.type };
} else {
responseType = "redirect";
}
break;
case "loom":
if (r.subtitles) {
params = { type: "remux" };
} else {
responseType = "redirect";
}
break;
case "vk":
case "tiktok":
params = {
type: r.subtitles ? "remux" : "proxy"
};
break;
case "ok":
case "xiaohongshu":
case "newgrounds":
params = { type: "proxy" };
break;
case "facebook":
case "instagram":
case "tumblr":
case "pinterest":
case "streamable":
case "snapchat":
case "twitch":
responseType = "redirect";
break;
}
break;
case "audio":
if (audioIgnore.has(host) || (host === "reddit" && r.typeId === "redirect")) {
return createResponse("error", {
code: "error.api.service.audio_not_supported"
})
}
let processType = "audio";
let copy = false;
if (audioFormat === "best") {
const serviceBestAudio = r.bestAudio;
if (serviceBestAudio) {
audioFormat = serviceBestAudio;
processType = "proxy";
if (host === "soundcloud") {
processType = "audio";
copy = true;
}
} else {
audioFormat = "m4a";
copy = true;
}
}
if (r.isHLS || host === "vimeo") {
copy = false;
processType = "audio";
}
params = {
type: processType,
url: Array.isArray(r.urls) ? r.urls[1] : r.urls,
audioBitrate,
audioCopy: copy,
audioFormat,
isHLS: r.isHLS,
}
break;
}
if (defaultParams.filename && (action === "picker" || action === "audio")) {
defaultParams.filename += `.${audioFormat}`;
}
// alwaysProxy is set to true in match.js if localProcessing is forced
if (alwaysProxy && responseType === "redirect") {
responseType = "tunnel";
params.type = "proxy";
}
// TODO: add support for HLS
// (very painful)
if (!params.isHLS && responseType !== "picker") {
const isPreferredWithExtra =
localProcessing === "preferred" && extraProcessingTypes.has(params.type);
if (localProcessing === "forced" || isPreferredWithExtra) {
responseType = "local-processing";
}
}
// extractors usually return ISO 639-1 language codes,
// but video players expect ISO 639-2, so we convert them here
const sublanguage = defaultParams.fileMetadata?.sublanguage;
if (sublanguage && sublanguage.length !== 3) {
const code = convertLanguageCode(sublanguage);
if (code) {
defaultParams.fileMetadata.sublanguage = code;
} else {
// if a language code couldn't be converted,
// then we don't want it at all
delete defaultParams.fileMetadata.sublanguage;
}
}
return createResponse(
responseType,
{ ...defaultParams, ...params }
);
}

353
api/src/processing/match.js Normal file
View File

@@ -0,0 +1,353 @@
import { strict as assert } from "node:assert";
import { env } from "../config.js";
import { createResponse } from "../processing/request.js";
import { testers } from "./service-patterns.js";
import matchAction from "./match-action.js";
import { friendlyServiceName } from "./service-alias.js";
import bilibili from "./services/bilibili.js";
import reddit from "./services/reddit.js";
import twitter from "./services/twitter.js";
import youtube from "./services/youtube.js";
import vk from "./services/vk.js";
import ok from "./services/ok.js";
import tiktok from "./services/tiktok.js";
import tumblr from "./services/tumblr.js";
import vimeo from "./services/vimeo.js";
import soundcloud from "./services/soundcloud.js";
import instagram from "./services/instagram.js";
import pinterest from "./services/pinterest.js";
import streamable from "./services/streamable.js";
import twitch from "./services/twitch.js";
import rutube from "./services/rutube.js";
import dailymotion from "./services/dailymotion.js";
import snapchat from "./services/snapchat.js";
import loom from "./services/loom.js";
import facebook from "./services/facebook.js";
import bluesky from "./services/bluesky.js";
import xiaohongshu from "./services/xiaohongshu.js";
import newgrounds from "./services/newgrounds.js";
let freebind;
export default async function({ host, patternMatch, params, authType }) {
const { url } = params;
assert(url instanceof URL);
let dispatcher, requestIP;
if (env.freebindCIDR) {
if (!freebind) {
freebind = await import('freebind');
}
requestIP = freebind.ip.random(env.freebindCIDR);
dispatcher = freebind.dispatcherFromIP(requestIP, { strict: false });
}
try {
let r,
isAudioOnly = params.downloadMode === "audio",
isAudioMuted = params.downloadMode === "mute";
if (!testers[host]) {
return createResponse("error", {
code: "error.api.service.unsupported"
});
}
if (!(testers[host](patternMatch))) {
return createResponse("error", {
code: "error.api.link.unsupported",
context: {
service: friendlyServiceName(host),
}
});
}
// youtubeHLS will be fully removed in the future
let youtubeHLS = params.youtubeHLS;
const hlsEnv = env.enableDeprecatedYoutubeHls;
if (hlsEnv === "never" || (hlsEnv === "key" && authType !== "key")) {
youtubeHLS = false;
}
const subtitleLang =
params.subtitleLang !== "none" ? params.subtitleLang : undefined;
switch (host) {
case "twitter":
r = await twitter({
id: patternMatch.id,
index: patternMatch.index - 1,
toGif: !!params.convertGif,
alwaysProxy: params.alwaysProxy,
dispatcher,
subtitleLang
});
break;
case "vk":
r = await vk({
ownerId: patternMatch.ownerId,
videoId: patternMatch.videoId,
accessKey: patternMatch.accessKey,
quality: params.videoQuality,
subtitleLang,
});
break;
case "ok":
r = await ok({
id: patternMatch.id,
quality: params.videoQuality
});
break;
case "bilibili":
r = await bilibili(patternMatch);
break;
case "youtube":
let fetchInfo = {
dispatcher,
id: patternMatch.id.slice(0, 11),
quality: params.videoQuality,
codec: params.youtubeVideoCodec,
container: params.youtubeVideoContainer,
isAudioOnly,
isAudioMuted,
dubLang: params.youtubeDubLang,
youtubeHLS,
subtitleLang,
}
if (url.hostname === "music.youtube.com" || isAudioOnly) {
fetchInfo.quality = "1080";
fetchInfo.codec = "vp9";
fetchInfo.isAudioOnly = true;
fetchInfo.isAudioMuted = false;
if (env.ytAllowBetterAudio && params.youtubeBetterAudio) {
fetchInfo.quality = "max";
}
}
r = await youtube(fetchInfo);
break;
case "reddit":
r = await reddit({
...patternMatch,
dispatcher,
});
break;
case "tiktok":
r = await tiktok({
postId: patternMatch.postId,
shortLink: patternMatch.shortLink,
fullAudio: params.tiktokFullAudio,
isAudioOnly,
h265: params.allowH265,
alwaysProxy: params.alwaysProxy,
subtitleLang,
});
break;
case "tumblr":
r = await tumblr({
id: patternMatch.id,
user: patternMatch.user,
url
});
break;
case "vimeo":
r = await vimeo({
id: patternMatch.id.slice(0, 11),
password: patternMatch.password,
quality: params.videoQuality,
isAudioOnly,
subtitleLang,
});
break;
case "soundcloud":
isAudioOnly = true;
isAudioMuted = false;
r = await soundcloud({
...patternMatch,
format: params.audioFormat,
});
break;
case "instagram":
r = await instagram({
...patternMatch,
quality: params.videoQuality,
alwaysProxy: params.alwaysProxy,
dispatcher
})
break;
case "pinterest":
r = await pinterest({
id: patternMatch.id,
shortLink: patternMatch.shortLink || false
});
break;
case "streamable":
r = await streamable({
id: patternMatch.id,
quality: params.videoQuality,
isAudioOnly,
});
break;
case "twitch":
r = await twitch({
clipId: patternMatch.clip || false,
quality: params.videoQuality,
isAudioOnly,
});
break;
case "rutube":
r = await rutube({
id: patternMatch.id,
yappyId: patternMatch.yappyId,
key: patternMatch.key,
quality: params.videoQuality,
isAudioOnly,
subtitleLang,
});
break;
case "dailymotion":
r = await dailymotion(patternMatch);
break;
case "snapchat":
r = await snapchat({
...patternMatch,
alwaysProxy: params.alwaysProxy,
});
break;
case "loom":
r = await loom({
id: patternMatch.id,
subtitleLang,
});
break;
case "facebook":
r = await facebook({
...patternMatch,
dispatcher
});
break;
case "bsky":
r = await bluesky({
...patternMatch,
alwaysProxy: params.alwaysProxy,
dispatcher
});
break;
case "xiaohongshu":
r = await xiaohongshu({
...patternMatch,
h265: params.allowH265,
isAudioOnly,
dispatcher,
});
break;
case "newgrounds":
r = await newgrounds({
...patternMatch,
quality: params.videoQuality,
});
break;
default:
return createResponse("error", {
code: "error.api.service.unsupported"
});
}
if (r.isAudioOnly) {
isAudioOnly = true;
isAudioMuted = false;
}
if (r.error && r.critical) {
return createResponse("critical", {
code: `error.api.${r.error}`,
})
}
if (r.error) {
let context;
switch(r.error) {
case "content.too_long":
context = {
limit: parseFloat((env.durationLimit / 60).toFixed(2)),
}
break;
case "fetch.fail":
case "fetch.rate":
case "fetch.critical":
case "link.unsupported":
case "content.video.unavailable":
context = {
service: friendlyServiceName(host),
}
break;
}
return createResponse("error", {
code: `error.api.${r.error}`,
context,
})
}
let localProcessing = params.localProcessing;
const lpEnv = env.forceLocalProcessing;
const shouldForceLocal = lpEnv === "always" || (lpEnv === "session" && authType === "session");
const localDisabled = (!localProcessing || localProcessing === "disabled");
if (shouldForceLocal && localDisabled) {
localProcessing = "preferred";
}
return matchAction({
r,
host,
audioFormat: params.audioFormat,
isAudioOnly,
isAudioMuted,
disableMetadata: params.disableMetadata,
filenameStyle: params.filenameStyle,
convertGif: params.convertGif,
requestIP,
audioBitrate: params.audioBitrate,
alwaysProxy: params.alwaysProxy || localProcessing === "forced",
localProcessing,
})
} catch {
return createResponse("error", {
code: "error.api.fetch.critical",
context: {
service: friendlyServiceName(host),
}
})
}
}

View File

@@ -0,0 +1,140 @@
import mime from "mime";
import ipaddr from "ipaddr.js";
import { apiSchema } from "./schema.js";
import { createProxyTunnels, createStream } from "../stream/manage.js";
export function createResponse(responseType, responseData) {
const internalError = (code) => {
return {
status: 500,
body: {
status: "error",
error: {
code: code || "error.api.fetch.critical.core",
},
critical: true
}
}
}
try {
let status = 200,
response = {};
if (responseType === "error") {
status = 400;
}
switch (responseType) {
case "error":
response = {
error: {
code: responseData?.code,
context: responseData?.context,
}
}
break;
case "redirect":
response = {
url: responseData?.url,
filename: responseData?.filename
}
break;
case "tunnel":
response = {
url: createStream(responseData),
filename: responseData?.filename
}
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,
subtitles: !!responseData?.subtitles || undefined,
},
audio: {
copy: responseData?.audioCopy,
format: responseData?.audioFormat,
bitrate: responseData?.audioBitrate,
cover: !!responseData?.cover || undefined,
cropCover: !!responseData?.cropCover || undefined,
},
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":
response = {
picker: responseData?.picker,
audio: responseData?.url,
audioFilename: responseData?.filename
}
break;
case "critical":
return internalError(responseData?.code);
default:
throw "unreachable"
}
return {
status,
body: {
status: responseType,
...response
}
}
} catch {
return internalError();
}
}
export function normalizeRequest(request) {
// TODO: remove after backwards compatibility period
if ("localProcessing" in request && typeof request.localProcessing === "boolean") {
request.localProcessing = request.localProcessing ? "preferred" : "disabled";
}
return apiSchema.safeParseAsync(request).catch(() => (
{ success: false }
));
}
export function getIP(req, prefix = 56) {
const strippedIP = req.ip.replace(/^::ffff:/, '');
const ip = ipaddr.parse(strippedIP);
if (ip.kind() === 'ipv4') {
return strippedIP;
}
const v6Bytes = ip.toByteArray();
v6Bytes.fill(0, prefix / 8);
return ipaddr.fromByteArray(v6Bytes).toString();
}

View File

@@ -0,0 +1,64 @@
import { z } from "zod";
import { normalizeURL } from "./url.js";
export const apiSchema = z.object({
url: z.string()
.min(1)
.transform(url => normalizeURL(url)),
audioBitrate: z.enum(
["320", "256", "128", "96", "64", "8"]
).default("128"),
audioFormat: z.enum(
["best", "mp3", "ogg", "wav", "opus"]
).default("mp3"),
downloadMode: z.enum(
["auto", "audio", "mute"]
).default("auto"),
filenameStyle: z.enum(
["classic", "pretty", "basic", "nerdy"]
).default("basic"),
youtubeVideoCodec: z.enum(
["h264", "av1", "vp9"]
).default("h264"),
youtubeVideoContainer: z.enum(
["auto", "mp4", "webm", "mkv"]
).default("auto"),
videoQuality: z.enum(
["max", "4320", "2160", "1440", "1080", "720", "480", "360", "240", "144"]
).default("1080"),
localProcessing: z.enum(
["disabled", "preferred", "forced"]
).default("disabled"),
youtubeDubLang: z.string()
.min(2)
.max(8)
.regex(/^[0-9a-zA-Z\-]+$/)
.optional(),
subtitleLang: z.string()
.min(2)
.max(8)
.regex(/^[0-9a-zA-Z\-]+$/)
.optional(),
disableMetadata: z.boolean().default(false),
allowH265: z.boolean().default(false),
convertGif: z.boolean().default(true),
tiktokFullAudio: z.boolean().default(false),
alwaysProxy: z.boolean().default(false),
youtubeHLS: z.boolean().default(false),
youtubeBetterAudio: z.boolean().default(false),
})
.strict();

View File

@@ -0,0 +1,11 @@
const friendlyNames = {
bsky: "bluesky",
twitch: "twitch clips"
}
export const friendlyServiceName = (service) => {
if (service in friendlyNames) {
return friendlyNames[service];
}
return service;
}

View File

@@ -0,0 +1,233 @@
import UrlPattern from "url-pattern";
export const audioIgnore = new Set(["vk", "ok", "loom"]);
export const hlsExceptions = new Set(["dailymotion", "vimeo", "rutube", "bsky", "youtube"]);
export const services = {
bilibili: {
patterns: [
"video/:comId",
"video/:comId?p=:partId",
"_shortLink/:comShortLink",
"_tv/:lang/video/:tvId",
"_tv/video/:tvId"
],
subdomains: ["m"],
},
bsky: {
patterns: [
"profile/:user/post/:post"
],
tld: "app",
},
dailymotion: {
patterns: ["video/:id"],
},
facebook: {
patterns: [
"_shortLink/:shortLink",
":username/videos/:caption/:id",
":username/videos/:id",
"reel/:id",
"share/:shareType/:id"
],
subdomains: ["web", "m"],
altDomains: ["fb.watch"],
},
instagram: {
patterns: [
"p/:postId",
"tv/:postId",
"reel/:postId",
"reels/:postId",
"stories/:username/:storyId",
/*
share & username links use the same url pattern,
so we test the share pattern first, cuz id type is different.
however, if someone has the "share" username and the user
somehow gets a link of this ancient style, it's joever.
*/
"share/:shareId",
"share/p/:shareId",
"share/reel/:shareId",
":username/p/:postId",
":username/reel/:postId",
],
altDomains: ["ddinstagram.com"],
},
loom: {
patterns: ["share/:id", "embed/:id"],
},
ok: {
patterns: [
"video/:id",
"videoembed/:id"
],
tld: "ru",
},
pinterest: {
patterns: [
"pin/:id",
"pin/:id/:garbage",
"url_shortener/:shortLink"
],
},
newgrounds: {
patterns: [
"portal/view/:id",
"audio/listen/:audioId",
]
},
reddit: {
patterns: [
"comments/:id",
"r/:sub/comments/:id",
"r/:sub/comments/:id/:title",
"r/:sub/comments/:id/comment/:commentId",
"user/:user/comments/:id",
"user/:user/comments/:id/:title",
"user/:user/comments/:id/comment/:commentId",
"r/u_:user/comments/:id",
"r/u_:user/comments/:id/:title",
"r/u_:user/comments/:id/comment/:commentId",
"r/:sub/s/:shareId",
"video/:shortId",
],
subdomains: "*",
},
rutube: {
patterns: [
"video/:id",
"play/embed/:id",
"shorts/:id",
"yappy/:yappyId",
"video/private/:id?p=:key",
"video/private/:id"
],
tld: "ru",
},
snapchat: {
patterns: [
":shortLink",
"spotlight/:spotlightId",
"add/:username/:storyId",
"u/:username/:storyId",
"add/:username",
"u/:username",
"t/:shortLink",
"o/:spotlightId",
],
subdomains: ["t", "story"],
},
soundcloud: {
patterns: [
":author/:song/s-:accessKey",
":author/:song",
":shortLink"
],
subdomains: ["on", "m"],
},
streamable: {
patterns: [
":id",
"o/:id",
"e/:id",
"s/:id"
],
},
tiktok: {
patterns: [
":user/video/:postId",
"i18n/share/video/:postId",
":shortLink",
"t/:shortLink",
":user/photo/:postId",
"v/:postId.html"
],
subdomains: ["vt", "vm", "m", "t"],
},
tumblr: {
patterns: [
"post/:id",
"blog/view/:user/:id",
":user/:id",
":user/:id/:trackingId"
],
subdomains: "*",
},
twitch: {
patterns: [":channel/clip/:clip"],
tld: "tv",
subdomains: ["clips", "www", "m"],
},
twitter: {
patterns: [
":user/status/:id",
":user/status/:id/video/:index",
":user/status/:id/photo/:index",
":user/status/:id/mediaviewer",
":user/status/:id/mediaViewer",
"i/bookmarks?post_id=:id"
],
subdomains: ["mobile"],
altDomains: ["x.com", "vxtwitter.com", "fixvx.com"],
},
vimeo: {
patterns: [
":id",
"video/:id",
":id/:password",
"/channels/:user/:id",
"groups/:groupId/videos/:id"
],
subdomains: ["player"],
},
vk: {
patterns: [
"video:ownerId_:videoId",
"clip:ownerId_:videoId",
"video:ownerId_:videoId_:accessKey",
"clip:ownerId_:videoId_:accessKey",
// links with a duplicate author id and/or zipper query param
"clips:duplicateId",
"videos:duplicateId",
"search/video"
],
subdomains: ["m"],
altDomains: ["vkvideo.ru", "vk.ru"],
},
xiaohongshu: {
patterns: [
"explore/:id?xsec_token=:token",
"discovery/item/:id?xsec_token=:token",
":shareType/:shareId",
],
altDomains: ["xhslink.com"],
},
youtube: {
patterns: [
"watch?v=:id",
"embed/:id",
"watch/:id",
"v/:id"
],
subdomains: ["music", "m"],
}
}
Object.values(services).forEach(service => {
service.patterns = service.patterns.map(
pattern => new UrlPattern(pattern, {
segmentValueCharset: UrlPattern.defaultOptions.segmentValueCharset + '@\\.:'
})
)
})

View File

@@ -0,0 +1,90 @@
export const testers = {
"bilibili": pattern =>
(pattern.comId?.length <= 12 && pattern.partId?.length <= 3) ||
(pattern.comId?.length <= 12 && !pattern.partId) ||
pattern.comShortLink?.length <= 16 ||
pattern.tvId?.length <= 24,
"bsky": pattern =>
pattern.user?.length <= 128 && pattern.post?.length <= 128,
"dailymotion": pattern => pattern.id?.length <= 32,
"facebook": pattern =>
pattern.shortLink?.length <= 11 ||
pattern.username?.length <= 30 ||
pattern.caption?.length <= 255 ||
pattern.id?.length <= 20 && !pattern.shareType ||
pattern.id?.length <= 20 && pattern.shareType?.length === 1,
"instagram": pattern =>
pattern.postId?.length <= 48 ||
pattern.shareId?.length <= 16 ||
(pattern.username?.length <= 30 && pattern.storyId?.length <= 24),
"loom": pattern =>
pattern.id?.length <= 32,
"newgrounds": pattern =>
pattern.id?.length <= 12 ||
pattern.audioId?.length <= 12,
"ok": pattern =>
pattern.id?.length <= 16,
"pinterest": pattern =>
pattern.id?.length <= 128 ||
pattern.shortLink?.length <= 32,
"reddit": pattern =>
pattern.id?.length <= 16 && !pattern.sub && !pattern.user ||
(pattern.sub?.length <= 22 && pattern.id?.length <= 16) ||
(pattern.user?.length <= 22 && pattern.id?.length <= 16) ||
(pattern.sub?.length <= 22 && pattern.shareId?.length <= 16) ||
(pattern.shortId?.length <= 16),
"rutube": pattern =>
(pattern.id?.length === 32 && pattern.key?.length <= 32) ||
pattern.id?.length === 32 ||
pattern.yappyId?.length === 32,
"snapchat": pattern =>
(pattern.username?.length <= 32 && (!pattern.storyId || pattern.storyId?.length <= 255)) ||
pattern.spotlightId?.length <= 255 ||
pattern.shortLink?.length <= 16,
"soundcloud": pattern =>
(pattern.author?.length <= 255 && pattern.song?.length <= 255) ||
pattern.shortLink?.length <= 32,
"streamable": pattern =>
pattern.id?.length <= 6,
"tiktok": pattern =>
pattern.postId?.length <= 21 ||
pattern.shortLink?.length <= 21,
"tumblr": pattern =>
pattern.id?.length < 21 ||
(pattern.id?.length < 21 && pattern.user?.length <= 32),
"twitch": pattern =>
pattern.channel && pattern.clip?.length <= 100,
"twitter": pattern =>
pattern.id?.length < 20,
"vimeo": pattern =>
pattern.id?.length <= 11 && (!pattern.password || pattern.password.length < 16),
"vk": pattern =>
(pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10) ||
(pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10 && pattern.videoId?.accessKey <= 18),
"xiaohongshu": pattern =>
pattern.id?.length <= 24 && pattern.token?.length <= 64 ||
pattern.shareId?.length <= 24 && pattern.shareType?.length === 1,
"youtube": pattern =>
pattern.id?.length <= 11,
}

View File

@@ -1,19 +1,8 @@
import { genericUserAgent, env } from "../../config.js";
import { resolveRedirectingURL } from "../url.js";
// TO-DO: higher quality downloads (currently requires an account)
function com_resolveShortlink(shortId) {
return fetch(`https://b23.tv/${shortId}`, { redirect: 'manual' })
.then(r => r.status > 300 && r.status < 400 && r.headers.get('location'))
.then(url => {
if (!url) return;
const path = new URL(url).pathname;
if (path.startsWith('/video/'))
return path.split('/')[2];
})
.catch(() => {})
}
function getBest(content) {
return content?.filter(v => v.baseUrl || v.url)
.map(v => (v.baseUrl = v.baseUrl || v.url, v))
@@ -28,30 +17,51 @@ function extractBestQuality(dashData) {
return [ bestVideo, bestAudio ];
}
async function com_download(id) {
let html = await fetch(`https://bilibili.com/video/${id}`, {
headers: { "user-agent": genericUserAgent }
}).then(r => r.text()).catch(() => {});
if (!html) return { error: 'ErrorCouldntFetch' };
async function com_download(id, partId) {
const url = new URL(`https://bilibili.com/video/${id}`);
if (!(html.includes('<script>window.__playinfo__=') && html.includes('"video_codecid"'))) {
return { error: 'ErrorEmptyDownload' };
if (partId) {
url.searchParams.set('p', partId);
}
let streamData = JSON.parse(html.split('<script>window.__playinfo__=')[1].split('</script>')[0]);
const html = await fetch(url, {
headers: {
"user-agent": genericUserAgent
}
})
.then(r => r.text())
.catch(() => {});
if (!html) {
return { error: "fetch.fail" }
}
if (!(html.includes('<script>window.__playinfo__=') && html.includes('"video_codecid"'))) {
return { error: "fetch.empty" };
}
const streamData = JSON.parse(
html.split('<script>window.__playinfo__=')[1].split('</script>')[0]
);
if (streamData.data.timelength > env.durationLimit * 1000) {
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
return { error: "content.too_long" };
}
const [ video, audio ] = extractBestQuality(streamData.data.dash);
if (!video || !audio) {
return { error: 'ErrorEmptyDownload' };
return { error: "fetch.empty" };
}
let filenameBase = `bilibili_${id}`;
if (partId) {
filenameBase += `_${partId}`;
}
return {
urls: [video.baseUrl, audio.baseUrl],
audioFilename: `bilibili_${id}_audio`,
filename: `bilibili_${id}_${video.width}x${video.height}.mp4`
audioFilename: `${filenameBase}_audio`,
filename: `${filenameBase}_${video.width}x${video.height}.mp4`,
};
}
@@ -66,7 +76,7 @@ async function tv_download(id) {
const { data } = await fetch(url).then(a => a.json());
if (!data?.playurl?.video) {
return { error: 'ErrorEmptyDownload' };
return { error: "fetch.empty" };
}
const [ video, audio ] = extractBestQuality({
@@ -76,11 +86,11 @@ async function tv_download(id) {
});
if (!video || !audio) {
return { error: 'ErrorEmptyDownload' };
return { error: "fetch.empty" };
}
if (video.duration > env.durationLimit * 1000) {
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
return { error: "content.too_long" };
}
return {
@@ -90,16 +100,17 @@ async function tv_download(id) {
};
}
export default async function({ comId, tvId, comShortLink }) {
export default async function({ comId, tvId, comShortLink, partId }) {
if (comShortLink) {
comId = await com_resolveShortlink(comShortLink);
const patternMatch = await resolveRedirectingURL(`https://b23.tv/${comShortLink}`);
comId = patternMatch?.comId;
}
if (comId) {
return com_download(comId);
return com_download(comId, partId);
} else if (tvId) {
return tv_download(tvId);
}
return { error: 'ErrorCouldntFetch' };
return { error: "fetch.fail" };
}

View File

@@ -0,0 +1,157 @@
import HLS from "hls-parser";
import { cobaltUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
const extractVideo = async ({ media, filename, dispatcher }) => {
let urlMasterHLS = media?.playlist;
if (!urlMasterHLS || !urlMasterHLS.startsWith("https://video.bsky.app/")) {
return { error: "fetch.empty" };
}
urlMasterHLS = urlMasterHLS.replace(
"video.bsky.app/watch/",
"video.cdn.bsky.app/hls/"
);
const masterHLS = await fetch(urlMasterHLS, { dispatcher })
.then(r => {
if (r.status !== 200) return;
return r.text();
})
.catch(() => {});
if (!masterHLS) return { error: "fetch.empty" };
const video = HLS.parse(masterHLS)
?.variants
?.reduce((a, b) => a?.bandwidth > b?.bandwidth ? a : b);
const videoURL = new URL(video.uri, urlMasterHLS).toString();
return {
urls: videoURL,
filename: `${filename}.mp4`,
audioFilename: `${filename}_audio`,
isHLS: true,
}
}
const extractImages = ({ getPost, filename, alwaysProxy }) => {
const images = getPost?.thread?.post?.embed?.images;
if (!images || images.length === 0) {
return { error: "fetch.empty" };
}
if (images.length === 1) return {
urls: images[0].fullsize,
isPhoto: true,
filename: `${filename}.jpg`,
}
const picker = images.map((image, i) => {
let url = image.fullsize;
let proxiedImage = createStream({
service: "bluesky",
type: "proxy",
url,
filename: `${filename}_${i + 1}.jpg`,
});
if (alwaysProxy) url = proxiedImage;
return {
type: "photo",
url,
thumb: proxiedImage,
}
});
return { picker };
}
const extractGif = ({ url, filename }) => {
const gifUrl = new URL(url);
if (!gifUrl || gifUrl.hostname !== "media.tenor.com") {
return { error: "fetch.empty" };
}
// remove downscaling params from gif url
// such as "?hh=498&ww=498"
gifUrl.search = "";
return {
urls: gifUrl,
isPhoto: true,
filename: `${filename}.gif`,
}
}
export default async function ({ user, post, alwaysProxy, dispatcher }) {
const apiEndpoint = new URL("https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?depth=0&parentHeight=0");
apiEndpoint.searchParams.set(
"uri",
`at://${user}/app.bsky.feed.post/${post}`
);
const getPost = await fetch(apiEndpoint, {
headers: {
"user-agent": cobaltUserAgent,
},
dispatcher
}).then(r => r.json()).catch(() => {});
if (!getPost) return { error: "fetch.empty" };
if (getPost.error) {
switch (getPost.error) {
case "NotFound":
case "InternalServerError":
return { error: "content.post.unavailable" };
case "InvalidRequest":
return { error: "link.unsupported" };
default:
return { error: "content.post.unavailable" };
}
}
const embedType = getPost?.thread?.post?.embed?.$type;
const filename = `bluesky_${user}_${post}`;
switch (embedType) {
case "app.bsky.embed.video#view":
return extractVideo({
media: getPost.thread?.post?.embed,
filename,
});
case "app.bsky.embed.images#view":
return extractImages({
getPost,
filename,
alwaysProxy
});
case "app.bsky.embed.external#view":
return extractGif({
url: getPost?.thread?.post?.embed?.external?.uri,
filename,
});
case "app.bsky.embed.recordWithMedia#view":
if (getPost?.thread?.post?.embed?.media?.$type === "app.bsky.embed.external#view") {
return extractGif({
url: getPost?.thread?.post?.embed?.media?.external?.uri,
filename,
});
}
return extractVideo({
media: getPost.thread?.post?.embed?.media,
filename,
});
}
return { error: "fetch.empty" };
}

View File

@@ -1,5 +1,5 @@
import HLSParser from 'hls-parser';
import { env } from '../../config.js';
import HLSParser from "hls-parser";
import { env } from "../../config.js";
let _token;
@@ -31,7 +31,7 @@ const getToken = async () => {
export default async function({ id }) {
const token = await getToken();
if (!token) return { error: 'ErrorSomethingWentWrong' };
if (!token) return { error: "fetch.fail" };
const req = await fetch('https://graphql.api.dailymotion.com/',
{
@@ -70,20 +70,20 @@ export default async function({ id }) {
const media = req?.data?.media;
if (media?.__typename !== 'Video' || !media.hlsURL) {
return { error: 'ErrorEmptyDownload' }
return { error: "fetch.empty" }
}
if (media.duration > env.durationLimit) {
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
return { error: "content.too_long" };
}
const manifest = await fetch(media.hlsURL).then(r => r.text()).catch(() => {});
if (!manifest) return { error: 'ErrorSomethingWentWrong' };
if (!manifest) return { error: "fetch.fail" };
const bestQuality = HLSParser.parse(manifest).variants
.filter(v => v.codecs.includes('avc1'))
.reduce((a, b) => a.bandwidth > b.bandwidth ? a : b);
if (!bestQuality) return { error: 'ErrorEmptyDownload' }
if (!bestQuality) return { error: "fetch.empty" }
const fileMetadata = {
title: media.title,
@@ -92,7 +92,7 @@ export default async function({ id }) {
return {
urls: bestQuality.uri,
isM3U8: true,
isHLS: true,
filenameAttributes: {
service: 'dailymotion',
id: media.xid,

View File

@@ -8,8 +8,8 @@ const headers = {
'Sec-Fetch-Site': 'none',
}
const resolveUrl = (url) => {
return fetch(url, { headers })
const resolveUrl = (url, dispatcher) => {
return fetch(url, { headers, dispatcher })
.then(r => {
if (r.headers.get('location')) {
return decodeURIComponent(r.headers.get('location'));
@@ -23,17 +23,18 @@ const resolveUrl = (url) => {
.catch(() => false);
}
export default async function({ id, shareType, shortLink }) {
export default async function({ id, shareType, shortLink, dispatcher }) {
let url = `https://web.facebook.com/i/videos/${id}`;
if (shareType) url = `https://web.facebook.com/share/${shareType}/${id}`;
if (shortLink) url = await resolveUrl(`https://fb.watch/${shortLink}`);
if (shortLink) url = await resolveUrl(`https://fb.watch/${shortLink}`, dispatcher);
const html = await fetch(url, { headers })
const html = await fetch(url, { headers, dispatcher })
.then(r => r.text())
.catch(() => false);
if (!html) return { error: 'ErrorCouldntFetch' };
if (!html && shortLink) return { error: "fetch.short_link" }
if (!html) return { error: "fetch.fail" };
const urls = [];
const hd = html.match('"browser_native_hd_url":(".*?")');
@@ -43,7 +44,7 @@ export default async function({ id, shareType, shortLink }) {
if (sd?.[1]) urls.push(JSON.parse(sd[1]));
if (!urls.length) {
return { error: 'ErrorEmptyDownload' };
return { error: "fetch.empty" };
}
const baseFilename = `facebook_${id || shortLink}`;

View File

@@ -0,0 +1,541 @@
import { randomBytes } from "node:crypto";
import { resolveRedirectingURL } from "../url.js";
import { genericUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
import { getCookie, updateCookie } from "../cookie/manager.js";
const commonHeaders = {
"user-agent": genericUserAgent,
"sec-gpc": "1",
"sec-fetch-site": "same-origin",
"x-ig-app-id": "936619743392459"
}
const mobileHeaders = {
"x-ig-app-locale": "en_US",
"x-ig-device-locale": "en_US",
"x-ig-mapped-locale": "en_US",
"user-agent": "Instagram 275.0.0.27.98 Android (33/13; 280dpi; 720x1423; Xiaomi; Redmi 7; onclite; qcom; en_US; 458229237)",
"accept-language": "en-US",
"x-fb-http-engine": "Liger",
"x-fb-client-ip": "True",
"x-fb-server-cluster": "True",
"content-length": "0",
}
const embedHeaders = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept-Language": "en-GB,en;q=0.9",
"Cache-Control": "max-age=0",
"Dnt": "1",
"Priority": "u=0, i",
"Sec-Ch-Ua": 'Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99',
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": "macOS",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": genericUserAgent,
}
const cachedDtsg = {
value: '',
expiry: 0
}
const getNumberFromQuery = (name, data) => {
const s = data?.match(new RegExp(name + '=(\\d+)'))?.[1];
if (+s) return +s;
}
const getObjectFromEntries = (name, data) => {
const obj = data?.match(new RegExp('\\["' + name + '",.*?,({.*?}),\\d+\\]'))?.[1];
return obj && JSON.parse(obj);
}
export default function instagram(obj) {
const dispatcher = obj.dispatcher;
async function findDtsgId(cookie) {
try {
if (cachedDtsg.expiry > Date.now()) return cachedDtsg.value;
const data = await fetch('https://www.instagram.com/', {
headers: {
...commonHeaders,
cookie
},
dispatcher
}).then(r => r.text());
const token = data.match(/"dtsg":{"token":"(.*?)"/)[1];
cachedDtsg.value = token;
cachedDtsg.expiry = Date.now() + 86390000;
if (token) return token;
return false;
}
catch {}
}
async function request(url, cookie, method = 'GET', requestData) {
let headers = {
...commonHeaders,
'x-ig-www-claim': cookie?._wwwClaim || '0',
'x-csrftoken': cookie?.values()?.csrftoken,
cookie
}
if (method === 'POST') {
headers['content-type'] = 'application/x-www-form-urlencoded';
}
const data = await fetch(url, {
method,
headers,
body: requestData && new URLSearchParams(requestData),
dispatcher
});
if (data.headers.get('X-Ig-Set-Www-Claim') && cookie)
cookie._wwwClaim = data.headers.get('X-Ig-Set-Www-Claim');
updateCookie(cookie, data.headers);
return data.json();
}
async function getMediaId(id, { cookie, token } = {}) {
const oembedURL = new URL('https://i.instagram.com/api/v1/oembed/');
oembedURL.searchParams.set('url', `https://www.instagram.com/p/${id}/`);
const oembed = await fetch(oembedURL, {
headers: {
...mobileHeaders,
...( token && { authorization: `Bearer ${token}` } ),
cookie
},
dispatcher
}).then(r => r.json()).catch(() => {});
return oembed?.media_id;
}
async function requestMobileApi(mediaId, { cookie, token } = {}) {
const mediaInfo = await fetch(`https://i.instagram.com/api/v1/media/${mediaId}/info/`, {
headers: {
...mobileHeaders,
...( token && { authorization: `Bearer ${token}` } ),
cookie
},
dispatcher
}).then(r => r.json()).catch(() => {});
return mediaInfo?.items?.[0];
}
async function requestHTML(id, cookie) {
const data = await fetch(`https://www.instagram.com/p/${id}/embed/captioned/`, {
headers: {
...embedHeaders,
cookie
},
dispatcher
}).then(r => r.text()).catch(() => {});
let embedData = JSON.parse(data?.match(/"init",\[\],\[(.*?)\]\],/)[1]);
if (!embedData || !embedData?.contextJSON) return false;
embedData = JSON.parse(embedData.contextJSON);
return embedData;
}
async function getGQLParams(id, cookie) {
const req = await fetch(`https://www.instagram.com/p/${id}/`, {
headers: {
...embedHeaders,
cookie
},
dispatcher
});
const html = await req.text();
const siteData = getObjectFromEntries('SiteData', html);
const polarisSiteData = getObjectFromEntries('PolarisSiteData', html);
const webConfig = getObjectFromEntries('DGWWebConfig', html);
const pushInfo = getObjectFromEntries('InstagramWebPushInfo', html);
const lsd = getObjectFromEntries('LSD', html)?.token || randomBytes(8).toString('base64url');
const csrf = getObjectFromEntries('InstagramSecurityConfig', html)?.csrf_token;
const anon_cookie = [
csrf && "csrftoken=" + csrf,
polarisSiteData?.device_id && "ig_did=" + polarisSiteData?.device_id,
"wd=1280x720",
"dpr=2",
polarisSiteData?.machine_id && "mid=" + polarisSiteData.machine_id,
"ig_nrcb=1"
].filter(a => a).join('; ');
return {
headers: {
'x-ig-app-id': webConfig?.appId || '936619743392459',
'X-FB-LSD': lsd,
'X-CSRFToken': csrf,
'X-Bloks-Version-Id': getObjectFromEntries('WebBloksVersioningID', html)?.versioningID,
'x-asbd-id': 129477,
cookie: anon_cookie
},
body: {
__d: 'www',
__a: '1',
__s: '::' + Math.random().toString(36).substring(2).replace(/\d/g, '').slice(0, 6),
__hs: siteData?.haste_session || '20126.HYP:instagram_web_pkg.2.1...0',
__req: 'b',
__ccg: 'EXCELLENT',
__rev: pushInfo?.rollout_hash || '1019933358',
__hsi: siteData?.hsi || '7436540909012459023',
__dyn: randomBytes(154).toString('base64url'),
__csr: randomBytes(154).toString('base64url'),
__user: '0',
__comet_req: getNumberFromQuery('__comet_req', html) || '7',
av: '0',
dpr: '2',
lsd,
jazoest: getNumberFromQuery('jazoest', html) || Math.floor(Math.random() * 10000),
__spin_r: siteData?.__spin_r || '1019933358',
__spin_b: siteData?.__spin_b || 'trunk',
__spin_t: siteData?.__spin_t || Math.floor(new Date().getTime() / 1000),
}
};
}
async function requestGQL(id, cookie) {
const { headers, body } = await getGQLParams(id, cookie);
const req = await fetch('https://www.instagram.com/graphql/query', {
method: 'POST',
dispatcher,
headers: {
...embedHeaders,
...headers,
cookie,
'content-type': 'application/x-www-form-urlencoded',
'X-FB-Friendly-Name': 'PolarisPostActionLoadPostQueryQuery',
},
body: new URLSearchParams({
...body,
fb_api_caller_class: 'RelayModern',
fb_api_req_friendly_name: 'PolarisPostActionLoadPostQueryQuery',
variables: JSON.stringify({
shortcode: id,
fetch_tagged_user_count: null,
hoisted_comment_id: null,
hoisted_reply_id: null
}),
server_timestamps: true,
doc_id: '8845758582119845'
}).toString()
});
return {
gql_data: await req.json()
.then(r => r.data)
.catch(() => null)
};
}
async function getErrorContext(id) {
try {
const { headers, body } = await getGQLParams(id);
const req = await fetch('https://www.instagram.com/ajax/bulk-route-definitions/', {
method: 'POST',
dispatcher,
headers: {
...embedHeaders,
...headers,
'content-type': 'application/x-www-form-urlencoded',
'X-Ig-D': 'www',
},
body: new URLSearchParams({
'route_urls[0]': `/p/${id}/`,
routing_namespace: 'igx_www',
...body
}).toString()
});
const response = await req.text();
if (response.includes('"tracePolicy":"polaris.privatePostPage"'))
return { error: 'content.post.private' };
const [, mediaId, mediaOwnerId] = response.match(
/"media_id":\s*?"(\d+)","media_owner_id":\s*?"(\d+)"/
) || [];
if (mediaId && mediaOwnerId) {
const rulingURL = new URL('https://www.instagram.com/api/v1/web/get_ruling_for_media_content_logged_out');
rulingURL.searchParams.set('media_id', mediaId);
rulingURL.searchParams.set('owner_id', mediaOwnerId);
const rulingResponse = await fetch(rulingURL, {
headers: {
...headers,
...commonHeaders
},
dispatcher,
}).then(a => a.json()).catch(() => ({}));
if (rulingResponse?.title?.includes('Restricted'))
return { error: "content.post.age" };
}
} catch {
return { error: "fetch.fail" };
}
return { error: "fetch.empty" };
}
function extractOldPost(data, id, alwaysProxy) {
const shortcodeMedia = data?.gql_data?.shortcode_media || data?.gql_data?.xdt_shortcode_media;
const sidecar = shortcodeMedia?.edge_sidecar_to_children;
if (sidecar) {
const picker = sidecar.edges.filter(e => e.node?.display_url)
.map((e, i) => {
const type = e.node?.is_video && e.node?.video_url ? "video" : "photo";
let url;
if (type === "video") {
url = e.node?.video_url;
} else if (type === "photo") {
url = e.node?.display_url;
}
let itemExt = type === "video" ? "mp4" : "jpg";
let proxyFile;
if (alwaysProxy) proxyFile = createStream({
service: "instagram",
type: "proxy",
url,
filename: `instagram_${id}_${i + 1}.${itemExt}`
});
return {
type,
url: proxyFile || url,
/* thumbnails have `Cross-Origin-Resource-Policy`
** set to `same-origin`, so we need to proxy them */
thumb: createStream({
service: "instagram",
type: "proxy",
url: e.node?.display_url,
filename: `instagram_${id}_${i + 1}.jpg`
})
}
});
if (picker.length) return { picker }
}
if (shortcodeMedia?.video_url) {
return {
urls: shortcodeMedia.video_url,
filename: `instagram_${id}.mp4`,
audioFilename: `instagram_${id}_audio`
}
}
if (shortcodeMedia?.display_url) {
return {
urls: shortcodeMedia.display_url,
isPhoto: true,
filename: `instagram_${id}.jpg`,
}
}
}
function extractNewPost(data, id, alwaysProxy) {
const carousel = data.carousel_media;
if (carousel) {
const picker = carousel.filter(e => e?.image_versions2)
.map((e, i) => {
const type = e.video_versions ? "video" : "photo";
const imageUrl = e.image_versions2.candidates[0].url;
let url = imageUrl;
let itemExt = type === "video" ? "mp4" : "jpg";
if (type === "video") {
const video = e.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a);
url = video.url;
}
let proxyFile;
if (alwaysProxy) proxyFile = createStream({
service: "instagram",
type: "proxy",
url,
filename: `instagram_${id}_${i + 1}.${itemExt}`
});
return {
type,
url: proxyFile || url,
/* thumbnails have `Cross-Origin-Resource-Policy`
** set to `same-origin`, so we need to always proxy them */
thumb: createStream({
service: "instagram",
type: "proxy",
url: imageUrl,
filename: `instagram_${id}_${i + 1}.jpg`
})
}
});
if (picker.length) return { picker }
} else if (data.video_versions) {
const video = data.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a)
return {
urls: video.url,
filename: `instagram_${id}.mp4`,
audioFilename: `instagram_${id}_audio`
}
} else if (data.image_versions2?.candidates) {
return {
urls: data.image_versions2.candidates[0].url,
isPhoto: true,
filename: `instagram_${id}.jpg`,
}
}
}
async function getPost(id, alwaysProxy) {
const hasData = (data) => data
&& data.gql_data !== null
&& data?.gql_data?.xdt_shortcode_media !== null;
let data, result;
try {
const cookie = getCookie('instagram');
const bearer = getCookie('instagram_bearer');
const token = bearer?.values()?.token;
// get media_id for mobile api, three methods
let media_id = await getMediaId(id);
if (!media_id && token) media_id = await getMediaId(id, { token });
if (!media_id && cookie) media_id = await getMediaId(id, { cookie });
// mobile api (bearer)
if (media_id && token) data = await requestMobileApi(media_id, { token });
// mobile api (no cookie, cookie)
if (media_id && !hasData(data)) data = await requestMobileApi(media_id);
if (media_id && cookie && !hasData(data)) data = await requestMobileApi(media_id, { cookie });
// html embed (no cookie, cookie)
if (!hasData(data)) data = await requestHTML(id);
if (!hasData(data) && cookie) data = await requestHTML(id, cookie);
// web app graphql api (no cookie, cookie)
if (!hasData(data)) data = await requestGQL(id);
if (!hasData(data) && cookie) data = await requestGQL(id, cookie);
} catch {}
if (!hasData(data)) {
return getErrorContext(id);
}
if (data?.gql_data) {
result = extractOldPost(data, id, alwaysProxy)
} else {
result = extractNewPost(data, id, alwaysProxy)
}
if (result) return result;
return { error: "fetch.empty" }
}
async function usernameToId(username, cookie) {
const url = new URL('https://www.instagram.com/api/v1/users/web_profile_info/');
url.searchParams.set('username', username);
try {
const data = await request(url, cookie);
return data?.data?.user?.id;
} catch {}
}
async function getStory(username, id) {
const cookie = getCookie('instagram');
if (!cookie) return { error: "link.unsupported" };
const userId = await usernameToId(username, cookie);
if (!userId) return { error: "fetch.empty" };
const dtsgId = await findDtsgId(cookie);
const url = new URL('https://www.instagram.com/api/graphql/');
const requestData = {
fb_dtsg: dtsgId,
jazoest: '26438',
variables: JSON.stringify({
reel_ids_arr : [ userId ],
}),
server_timestamps: true,
doc_id: '25317500907894419'
};
let media;
try {
const data = (await request(url, cookie, 'POST', requestData));
media = data?.data?.xdt_api__v1__feed__reels_media?.reels_media?.find(m => m.id === userId);
} catch {}
const item = media.items.find(m => m.pk === id);
if (!item) return { error: "fetch.empty" };
if (item.video_versions) {
const video = item.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a)
return {
urls: video.url,
filename: `instagram_${id}.mp4`,
audioFilename: `instagram_${id}_audio`
}
}
if (item.image_versions2?.candidates) {
return {
urls: item.image_versions2.candidates[0].url,
isPhoto: true,
filename: `instagram_${id}.jpg`,
}
}
return { error: "link.unsupported" };
}
const { postId, shareId, storyId, username, alwaysProxy } = obj;
if (shareId) {
return resolveRedirectingURL(
`https://www.instagram.com/share/${shareId}/`,
dispatcher,
// for some reason instagram decides to return HTML
// instead of a redirect when requesting with a normal
// browser user-agent
{'User-Agent': 'curl/7.88.1'}
).then(match => instagram({
...obj, ...match,
shareId: undefined
}));
}
if (postId) return getPost(postId, alwaysProxy);
if (username && storyId) return getStory(username, storyId);
return { error: "fetch.empty" }
}

View File

@@ -0,0 +1,108 @@
import { genericUserAgent } from "../../config.js";
const craftHeaders = id => ({
"user-agent": genericUserAgent,
"content-type": "application/json",
origin: "https://www.loom.com",
referer: `https://www.loom.com/share/${id}`,
cookie: `loom_referral_video=${id};`,
"x-loom-request-source": "loom_web_be851af",
});
async function fromTranscodedURL(id) {
const gql = await fetch(`https://www.loom.com/api/campaigns/sessions/${id}/transcoded-url`, {
method: "POST",
headers: craftHeaders(id),
body: JSON.stringify({
force_original: false,
password: null,
anonID: null,
deviceID: null
})
})
.then(r => r.status === 200 && r.json())
.catch(() => {});
if (gql?.url?.includes('.mp4?')) {
return gql.url;
}
}
async function fromRawURL(id) {
const gql = await fetch(`https://www.loom.com/api/campaigns/sessions/${id}/raw-url`, {
method: "POST",
headers: craftHeaders(id),
body: JSON.stringify({
anonID: crypto.randomUUID(),
client_name: "web",
client_version: "be851af",
deviceID: null,
force_original: false,
password: null,
supported_mime_types: ["video/mp4"],
})
})
.then(r => r.status === 200 && r.json())
.catch(() => {});
if (gql?.url?.includes('.mp4?')) {
return gql.url;
}
}
async function getTranscript(id) {
const gql = await fetch(`https://www.loom.com/graphql`, {
method: "POST",
headers: craftHeaders(id),
body: JSON.stringify({
operationName: "FetchVideoTranscriptForFetchTranscript",
variables: {
videoId: id,
password: null,
},
query: `
query FetchVideoTranscriptForFetchTranscript($videoId: ID!, $password: String) {
fetchVideoTranscript(videoId: $videoId, password: $password) {
... on VideoTranscriptDetails {
captions_source_url
language
__typename
}
... on GenericError {
message
__typename
}
__typename
}
}`,
})
})
.then(r => r.status === 200 && r.json())
.catch(() => {});
if (gql?.data?.fetchVideoTranscript?.captions_source_url?.includes('.vtt?')) {
return gql.data.fetchVideoTranscript.captions_source_url;
}
}
export default async function({ id, subtitleLang }) {
let url = await fromTranscodedURL(id);
url ??= await fromRawURL(id);
if (!url) {
return { error: "fetch.empty" }
}
let subtitles;
if (subtitleLang) {
const transcript = await getTranscript(id);
if (transcript) subtitles = transcript;
}
return {
urls: url,
subtitles,
filename: `loom_${id}.mp4`,
audioFilename: `loom_${id}_audio`
}
}

View File

@@ -0,0 +1,103 @@
import { genericUserAgent } from "../../config.js";
const getVideo = async ({ id, quality }) => {
const json = await fetch(`https://www.newgrounds.com/portal/video/${id}`, {
headers: {
"User-Agent": genericUserAgent,
"X-Requested-With": "XMLHttpRequest", // required to get the JSON response
}
})
.then(r => r.json())
.catch(() => {});
if (!json) return { error: "fetch.empty" };
const videoSources = json.sources;
const videoQualities = Object.keys(videoSources);
if (videoQualities.length === 0) {
return { error: "fetch.empty" };
}
const bestVideo = videoSources[videoQualities[0]]?.[0],
userQuality = quality === "2160" ? "4k" : `${quality}p`,
preferredVideo = videoSources[userQuality]?.[0],
video = preferredVideo || bestVideo,
videoQuality = preferredVideo ? userQuality : videoQualities[0];
if (!bestVideo || !video.type.includes("mp4")) {
return { error: "fetch.empty" };
}
const fileMetadata = {
title: decodeURIComponent(json.title),
artist: decodeURIComponent(json.author),
}
return {
urls: video.src,
filenameAttributes: {
service: "newgrounds",
id,
title: fileMetadata.title,
author: fileMetadata.artist,
extension: "mp4",
qualityLabel: videoQuality,
resolution: videoQuality,
},
fileMetadata,
}
}
const getMusic = async ({ id }) => {
const html = await fetch(`https://www.newgrounds.com/audio/listen/${id}`, {
headers: {
"User-Agent": genericUserAgent,
}
})
.then(r => r.text())
.catch(() => {});
if (!html) return { error: "fetch.fail" };
const params = JSON.parse(
`{${html.split(',"params":{')[1]?.split(',"images":')[0]}}`
);
if (!params) return { error: "fetch.empty" };
if (!params.name || !params.artist || !params.filename || !params.icon) {
return { error: "fetch.empty" };
}
const fileMetadata = {
title: decodeURIComponent(params.name),
artist: decodeURIComponent(params.artist),
}
return {
urls: params.filename,
filenameAttributes: {
service: "newgrounds",
id,
title: fileMetadata.title,
author: fileMetadata.artist,
},
fileMetadata,
cover:
params.icon.includes(".png?") || params.icon.includes(".jpg?")
? params.icon
: undefined,
isAudioOnly: true,
bestAudio: "mp3",
}
}
export default function({ id, audioId, quality }) {
if (id) {
return getVideo({ id, quality });
} else if (audioId) {
return getMusic({ id: audioId });
}
return { error: "fetch.empty" };
}

View File

@@ -1,5 +1,4 @@
import { genericUserAgent, env } from "../../config.js";
import { cleanString } from "../../sub/utils.js";
const resolutions = {
"ultra": "2160",
@@ -19,33 +18,33 @@ export default async function(o) {
headers: { "user-agent": genericUserAgent }
}).then(r => r.text()).catch(() => {});
if (!html) return { error: 'ErrorCouldntFetch' };
if (!html) return { error: "fetch.fail" };
let videoData = html.match(/<div data-module="OKVideo" .*? data-options="({.*?})"( .*?)>/)
?.[1]
?.replaceAll("&quot;", '"');
if (!videoData) {
return { error: 'ErrorEmptyDownload' };
return { error: "fetch.empty" };
}
videoData = JSON.parse(JSON.parse(videoData).flashvars.metadata);
if (videoData.provider !== "UPLOADED_ODKL")
return { error: 'ErrorUnsupported' };
return { error: "link.unsupported" };
if (videoData.movie.is_live)
return { error: 'ErrorLiveVideo' };
return { error: "content.video.live" };
if (videoData.movie.duration > env.durationLimit)
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
return { error: "content.too_long" };
let videos = videoData.videos.filter(v => !v.disallowed);
let bestVideo = videos.find(v => resolutions[v.name] === quality) || videos[videos.length - 1];
let fileMetadata = {
title: cleanString(videoData.movie.title.trim()),
author: cleanString((videoData.author?.name || videoData.compilationTitle).trim()),
title: videoData.movie.title.trim(),
author: (videoData.author?.name || videoData.compilationTitle)?.trim(),
}
if (bestVideo) return {
@@ -61,5 +60,5 @@ export default async function(o) {
}
}
return { error: 'ErrorEmptyDownload' }
return { error: "fetch.empty" }
}

View File

@@ -0,0 +1,52 @@
import { genericUserAgent } from "../../config.js";
import { resolveRedirectingURL } from "../url.js";
const videoRegex = /"url":"(https:\/\/v1\.pinimg\.com\/videos\/.*?)"/g;
const imageRegex = /src="(https:\/\/i\.pinimg\.com\/.*\.(jpg|gif))"/g;
const notFoundRegex = /"__typename"\s*:\s*"PinNotFound"/;
export default async function(o) {
let id = o.id;
if (!o.id && o.shortLink) {
const patternMatch = await resolveRedirectingURL(`https://api.pinterest.com/url_shortener/${o.shortLink}/redirect/`);
id = patternMatch?.id;
}
if (id.includes("--")) id = id.split("--")[1];
if (!id) return { error: "fetch.fail" };
const html = await fetch(`https://www.pinterest.com/pin/${id}/`, {
headers: { "user-agent": genericUserAgent }
}).then(r => r.text()).catch(() => {});
const invalidPin = html.match(notFoundRegex);
if (invalidPin) return { error: "fetch.empty" };
if (!html) return { error: "fetch.fail" };
const videoLink = [...html.matchAll(videoRegex)]
.map(([, link]) => link)
.find(a => a.endsWith('.mp4'));
if (videoLink) return {
urls: videoLink,
filename: `pinterest_${id}.mp4`,
audioFilename: `pinterest_${id}_audio`
}
const imageLink = [...html.matchAll(imageRegex)]
.map(([, link]) => link)
.find(a => a.endsWith('.jpg') || a.endsWith('.gif'));
const imageType = imageLink.endsWith(".gif") ? "gif" : "jpg"
if (imageLink) return {
urls: imageLink,
isPhoto: true,
filename: `pinterest_${id}.${imageType}`
}
return { error: "fetch.empty" };
}

View File

@@ -1,3 +1,4 @@
import { resolveRedirectingURL } from "../url.js";
import { genericUserAgent, env } from "../../config.js";
import { getCookie, updateCookieValues } from "../cookie/manager.js";
@@ -9,7 +10,7 @@ async function getAccessToken() {
* you can get these by making a reddit app and
* authenticating an account against reddit's oauth2 api
* see: https://github.com/reddit-archive/reddit/wiki/OAuth2
*
*
* any additional cookie fields are managed by this code and you
* should not touch them unless you know what you're doing. **/
const cookie = await getCookie('reddit');
@@ -48,42 +49,66 @@ async function getAccessToken() {
}
export default async function(obj) {
let url = new URL(`https://www.reddit.com/r/${obj.sub}/comments/${obj.id}.json`);
let params = obj;
const accessToken = await getAccessToken();
const headers = {
'user-agent': genericUserAgent,
authorization: accessToken && `Bearer ${accessToken}`,
accept: 'application/json'
};
if (obj.user) {
url.pathname = `/user/${obj.user}/comments/${obj.id}.json`;
if (params.shortId) {
params = await resolveRedirectingURL(
`https://www.reddit.com/video/${params.shortId}`,
obj.dispatcher, headers
);
}
const accessToken = await getAccessToken();
if (!params.id && params.shareId) {
params = await resolveRedirectingURL(
`https://www.reddit.com/r/${params.sub}/s/${params.shareId}`,
obj.dispatcher, headers
);
}
if (!params?.id) return { error: "fetch.short_link" };
const url = new URL(`https://www.reddit.com/comments/${params.id}.json`);
if (accessToken) url.hostname = 'oauth.reddit.com';
let data = await fetch(
url, {
headers: {
'User-Agent': genericUserAgent,
accept: 'application/json',
authorization: accessToken && `Bearer ${accessToken}`
}
}
url, { headers }
).then(r => r.json()).catch(() => {});
if (!data || !Array.isArray(data)) return { error: 'ErrorCouldntFetch' };
if (!data || !Array.isArray(data)) {
return { error: "fetch.fail" }
}
data = data[0]?.data?.children[0]?.data;
let sourceId;
if (params.sub || params.user) {
sourceId = `${String(params.sub || params.user).toLowerCase()}_${params.id}`;
} else {
sourceId = params.id;
}
if (data?.url?.endsWith('.gif')) return {
typeId: "redirect",
urls: data.url
urls: data.url,
filename: `reddit_${sourceId}.gif`,
}
if (!data.secure_media?.reddit_video)
return { error: 'ErrorEmptyDownload' };
return { error: "fetch.empty" };
if (data.secure_media?.reddit_video?.duration > env.durationLimit)
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
return { error: "content.too_long" };
const video = data.secure_media?.reddit_video?.fallback_url?.split('?')[0];
let audio = false,
video = data.secure_media?.reddit_video?.fallback_url?.split('?')[0],
audioFileLink = `${data.secure_media?.reddit_video?.fallback_url?.split('DASH')[0]}audio`;
if (video.match('.mp4')) {
@@ -107,18 +132,16 @@ export default async function(obj) {
}).catch(() => {})
}
let id = `${String(obj.sub).toLowerCase()}_${obj.id}`;
if (!audio) return {
typeId: "redirect",
urls: video
}
return {
typeId: "stream",
type: "render",
typeId: "tunnel",
type: "merge",
urls: [video, audioFileLink],
audioFilename: `reddit_${id}_audio`,
filename: `reddit_${id}.mp4`
audioFilename: `reddit_${sourceId}_audio`,
filename: `reddit_${sourceId}.mp4`
}
}

View File

@@ -1,7 +1,5 @@
import HLS from 'hls-parser';
import HLS from "hls-parser";
import { env } from "../../config.js";
import { cleanString } from '../../sub/utils.js';
async function requestJSON(url) {
try {
@@ -18,7 +16,7 @@ export default async function(obj) {
`https://rutube.ru/pangolin/api/web/yappy/yappypage/?client=wdp&videoId=${obj.yappyId}&page=1&page_size=15`
)
const yappyURL = yappy?.results?.find(r => r.id === obj.yappyId)?.link;
if (!yappyURL) return { error: 'ErrorEmptyDownload' };
if (!yappyURL) return { error: "fetch.empty" };
return {
urls: yappyURL,
@@ -33,19 +31,23 @@ export default async function(obj) {
if (obj.key) requestURL.searchParams.set('p', obj.key);
const play = await requestJSON(requestURL);
if (!play) return { error: 'ErrorCouldntFetch' };
if (!play) return { error: "fetch.fail" };
if (play.detail || !play.video_balancer) return { error: 'ErrorEmptyDownload' };
if (play.live_streams?.hls) return { error: 'ErrorLiveVideo' };
if (play.detail?.type === "blocking_rule") {
return { error: "content.video.region" };
}
if (play.detail || !play.video_balancer) return { error: "fetch.empty" };
if (play.live_streams?.hls) return { error: "content.video.live" };
if (play.duration > env.durationLimit * 1000)
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
return { error: "content.too_long" };
let m3u8 = await fetch(play.video_balancer.m3u8)
.then(r => r.text())
.catch(() => {});
if (!m3u8) return { error: 'ErrorCouldntFetch' };
if (!m3u8) return { error: "fetch.fail" };
m3u8 = HLS.parse(m3u8).variants;
@@ -59,13 +61,26 @@ export default async function(obj) {
});
const fileMetadata = {
title: cleanString(play.title.trim()),
artist: cleanString(play.author.name.trim()),
title: play.title.trim(),
artist: play.author.name.trim(),
}
let subtitles;
if (obj.subtitleLang && play.captions?.length) {
const subtitle = play.captions.find(
s => ["webvtt", "srt"].includes(s.format) && s.code.startsWith(obj.subtitleLang)
);
if (subtitle) {
subtitles = subtitle.file;
fileMetadata.sublanguage = obj.subtitleLang;
}
}
return {
urls: matchingQuality.uri,
isM3U8: true,
subtitles,
isHLS: true,
filenameAttributes: {
service: "rutube",
id: obj.id,

View File

@@ -0,0 +1,114 @@
import { resolveRedirectingURL } from "../url.js";
import { genericUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
const SPOTLIGHT_VIDEO_REGEX = /<link data-react-helmet="true" rel="preload" href="([^"]+)" as="video"\/>/;
const NEXT_DATA_REGEX = /<script id="__NEXT_DATA__" type="application\/json">({.+})<\/script><\/body><\/html>$/;
async function getSpotlight(id) {
const html = await fetch(`https://www.snapchat.com/spotlight/${id}`, {
headers: { 'user-agent': genericUserAgent }
}).then((r) => r.text()).catch(() => null);
if (!html) {
return { error: "fetch.fail" };
}
const videoURL = html.match(SPOTLIGHT_VIDEO_REGEX)?.[1];
if (videoURL && new URL(videoURL).hostname.endsWith(".sc-cdn.net")) {
return {
urls: videoURL,
filename: `snapchat_${id}.mp4`,
audioFilename: `snapchat_${id}_audio`
}
}
}
async function getStory(username, storyId, alwaysProxy) {
const html = await fetch(
`https://www.snapchat.com/add/${username}${storyId ? `/${storyId}` : ''}`,
{ headers: { 'user-agent': genericUserAgent } }
)
.then((r) => r.text())
.catch(() => null);
if (!html) {
return { error: "fetch.fail" };
}
const nextDataString = html.match(NEXT_DATA_REGEX)?.[1];
if (nextDataString) {
const data = JSON.parse(nextDataString);
const storyIdParam = data?.query?.profileParams?.[1];
if (storyIdParam && data?.props?.pageProps?.story) {
const story = data.props.pageProps.story.snapList.find((snap) => snap.snapId.value === storyIdParam);
if (story) {
if (story.snapMediaType === 0) {
return {
urls: story.snapUrls.mediaUrl,
filename: `snapchat_${storyId}.jpg`,
isPhoto: true
}
}
return {
urls: story.snapUrls.mediaUrl,
filename: `snapchat_${storyId}.mp4`,
audioFilename: `snapchat_${storyId}_audio`
}
}
}
const defaultStory = data?.props?.pageProps?.curatedHighlights?.[0];
if (defaultStory) {
return {
picker: defaultStory.snapList.map(snap => {
const snapType = snap.snapMediaType === 0 ? "photo" : "video";
const snapExt = snapType === "video" ? "mp4" : "jpg";
let snapUrl = snap.snapUrls.mediaUrl;
const proxy = createStream({
service: "snapchat",
type: "proxy",
url: snapUrl,
filename: `snapchat_${username}_${snap.timestampInSec.value}.${snapExt}`,
});
let thumbProxy;
if (snapType === "video") thumbProxy = createStream({
service: "snapchat",
type: "proxy",
url: snap.snapUrls.mediaPreviewUrl.value,
});
if (alwaysProxy) snapUrl = proxy;
return {
type: snapType,
url: snapUrl,
thumb: thumbProxy || proxy,
}
})
}
}
}
}
export default async function (obj) {
let params = obj;
if (obj.shortLink) {
params = await resolveRedirectingURL(`https://t.snapchat.com/${obj.shortLink}`);
}
if (params?.spotlightId) {
const result = await getSpotlight(params.spotlightId);
if (result) return result;
} else if (params?.username) {
const result = await getStory(params.username, params.storyId, obj.alwaysProxy);
if (result) return result;
}
return { error: "fetch.fail" };
}

View File

@@ -0,0 +1,173 @@
import { env } from "../../config.js";
import { resolveRedirectingURL } from "../url.js";
const cachedID = {
version: '',
id: ''
}
async function findClientID() {
try {
const sc = await fetch('https://soundcloud.com/').then(r => r.text()).catch(() => {});
const scVersion = String(sc.match(/<script>window\.__sc_version="[0-9]{10}"<\/script>/)[0].match(/[0-9]{10}/));
if (cachedID.version === scVersion) {
return cachedID.id;
}
const scripts = sc.matchAll(/<script.+src="(.+)">/g);
let clientid;
for (let script of scripts) {
const url = script[1];
if (!url?.startsWith('https://a-v2.sndcdn.com/')) {
return;
}
const scrf = await fetch(url).then(r => r.text()).catch(() => {});
const id = scrf.match(/\("client_id=[A-Za-z0-9]{32}"\)/);
if (id && typeof id[0] === 'string') {
clientid = id[0].match(/[A-Za-z0-9]{32}/)[0];
break;
}
}
cachedID.version = scVersion;
cachedID.id = clientid;
return clientid;
} catch {}
}
const findBestForPreset = (transcodings, preset) => {
let inferior;
for (const entry of transcodings) {
const protocol = entry?.format?.protocol;
if (entry.snipped || protocol?.includes('encrypted')) {
continue;
}
if (entry?.preset?.startsWith(`${preset}_`)) {
if (protocol === 'progressive') {
return entry;
}
inferior = entry;
}
}
return inferior;
}
export default async function(obj) {
const clientId = await findClientID();
if (!clientId) return { error: "fetch.fail" };
let link;
if (obj.shortLink) {
obj = {
...obj,
...await resolveRedirectingURL(
`https://on.soundcloud.com/${obj.shortLink}`
)
}
}
if (obj.author && obj.song) {
link = `https://soundcloud.com/${obj.author}/${obj.song}`;
if (obj.accessKey) {
link += `/s-${obj.accessKey}`;
}
}
if (!link && obj.shortLink) return { error: "fetch.short_link" };
if (!link) return { error: "link.unsupported" };
const resolveURL = new URL("https://api-v2.soundcloud.com/resolve");
resolveURL.searchParams.set("url", link);
resolveURL.searchParams.set("client_id", clientId);
const json = await fetch(resolveURL).then(r => r.json()).catch(() => {});
if (!json) return { error: "fetch.fail" };
if (json.duration > env.durationLimit * 1000) {
return { error: "content.too_long" };
}
if (json.policy === "BLOCK") {
return { error: "content.region" };
}
if (json.policy === "SNIP") {
return { error: "content.paid" };
}
if (!json.media?.transcodings || !json.media?.transcodings.length === 0) {
return { error: "fetch.empty" };
}
let bestAudio = "opus",
selectedStream = findBestForPreset(json.media.transcodings, "opus");
const mp3Media = findBestForPreset(json.media.transcodings, "mp3");
// use mp3 if present if user prefers it or if opus isn't available
if (mp3Media && (obj.format === "mp3" || !selectedStream)) {
selectedStream = mp3Media;
bestAudio = "mp3"
}
if (!selectedStream) {
return { error: "fetch.empty" };
}
const fileUrl = new URL(selectedStream.url);
fileUrl.searchParams.set("client_id", clientId);
fileUrl.searchParams.set("track_authorization", json.track_authorization);
const file = await fetch(fileUrl)
.then(async r => new URL((await r.json()).url))
.catch(() => {});
if (!file) return { error: "fetch.empty" };
const artist = json.user?.username?.trim();
const fileMetadata = {
title: json.title?.trim(),
album: json.publisher_metadata?.album_title?.trim(),
artist,
album_artist: artist,
composer: json.publisher_metadata?.writer_composer?.trim(),
genre: json.genre?.trim(),
date: json.display_date?.trim().slice(0, 10),
copyright: json.license?.trim(),
}
let cover;
if (json.artwork_url) {
const coverUrl = json.artwork_url.replace(/-large/, "-t1080x1080");
const testCover = await fetch(coverUrl)
.then(r => r.status === 200)
.catch(() => {});
if (testCover) {
cover = coverUrl;
}
}
return {
urls: file.toString(),
cover,
filenameAttributes: {
service: "soundcloud",
id: json.id,
...fileMetadata
},
bestAudio,
fileMetadata,
isHLS: file.pathname.endsWith('.m3u8'),
}
}

View File

@@ -3,9 +3,9 @@ export default async function(obj) {
.then(r => r.status === 200 ? r.json() : false)
.catch(() => {});
if (!video) return { error: 'ErrorEmptyDownload' };
if (!video) return { error: "fetch.empty" };
let best = video.files['mp4-mobile'];
let best = video.files["mp4-mobile"];
if (video.files.mp4 && (obj.isAudioOnly || obj.quality === "max" || obj.quality >= 720)) {
best = video.files.mp4;
}
@@ -18,5 +18,5 @@ export default async function(obj) {
title: video.title
}
}
return { error: 'ErrorEmptyDownload' }
return { error: "fetch.fail" }
}

View File

@@ -1,7 +1,10 @@
import Cookie from "../cookie/cookie.js";
import { extract, normalizeURL } from "../url.js";
import { genericUserAgent } from "../../config.js";
import { updateCookie } from "../cookie/manager.js";
import { extract } from "../url.js";
import Cookie from "../cookie/cookie.js";
import { createStream } from "../../stream/manage.js";
import { convertLanguageCode } from "../../misc/language-codes.js";
const shortDomain = "https://vt.tiktok.com/";
@@ -10,25 +13,27 @@ export default async function(obj) {
let postId = obj.postId;
if (!postId) {
let html = await fetch(`${shortDomain}${obj.id}`, {
let html = await fetch(`${shortDomain}${obj.shortLink}`, {
redirect: "manual",
headers: {
"user-agent": genericUserAgent.split(' Chrome/1')[0]
}
}).then(r => r.text()).catch(() => {});
if (!html) return { error: 'ErrorCouldntFetch' };
if (!html) return { error: "fetch.fail" };
if (html.startsWith('<a href="https://')) {
const extractedURL = html.split('<a href="')[1].split('?')[0];
const { patternMatch } = extract(extractedURL);
postId = patternMatch.postId
const { host, patternMatch } = extract(normalizeURL(extractedURL));
if (host === "tiktok") {
postId = patternMatch?.postId;
}
}
}
if (!postId) return { error: 'ErrorCantGetID' };
if (!postId) return { error: "fetch.short_link" };
// should always be /video/, even for photos
const res = await fetch(`https://tiktok.com/@i/video/${postId}`, {
const res = await fetch(`https://www.tiktok.com/@i/video/${postId}`, {
headers: {
"user-agent": genericUserAgent,
cookie,
@@ -42,20 +47,39 @@ export default async function(obj) {
try {
const json = html
.split('<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">')[1]
.split('</script>')[0]
const data = JSON.parse(json)
detail = data["__DEFAULT_SCOPE__"]["webapp.video-detail"]["itemInfo"]["itemStruct"]
.split('</script>')[0];
const data = JSON.parse(json);
const videoDetail = data["__DEFAULT_SCOPE__"]["webapp.video-detail"];
if (!videoDetail) throw "no video detail found";
// status_deleted or etc
if (videoDetail.statusMsg) {
return { error: "content.post.unavailable"};
}
detail = videoDetail?.itemInfo?.itemStruct;
} catch {
return { error: 'ErrorCouldntFetch' };
return { error: "fetch.fail" };
}
if (detail.isContentClassified) {
return { error: "content.post.age" };
}
if (!detail.author) {
return { error: "fetch.empty" };
}
let video, videoFilename, audioFilename, audio, images,
filenameBase = `tiktok_${detail.author.uniqueId}_${postId}`,
bestAudio = 'm4a';
filenameBase = `tiktok_${detail.author?.uniqueId}_${postId}`,
bestAudio; // will get defaulted to m4a later on in match-action
images = detail.imagePost?.images;
let playAddr = detail.video.playAddr;
let playAddr = detail.video?.playAddr;
if (obj.h265) {
const h265PlayAddr = detail?.video?.bitrateInfo?.find(b => b.CodecType.includes("h265"))?.PlayAddr.UrlList[0]
playAddr = h265PlayAddr || playAddr
@@ -76,8 +100,23 @@ export default async function(obj) {
}
if (video) {
let subtitles, fileMetadata;
if (obj.subtitleLang && detail?.video?.subtitleInfos?.length) {
const langCode = convertLanguageCode(obj.subtitleLang);
const subtitle = detail?.video?.subtitleInfos.find(
s => s.LanguageCodeName.startsWith(langCode) && s.Format === "webvtt"
)
if (subtitle) {
subtitles = subtitle.Url;
fileMetadata = {
sublanguage: langCode,
}
}
}
return {
urls: video,
subtitles,
fileMetadata,
filename: videoFilename,
headers: { cookie }
}
@@ -96,7 +135,19 @@ export default async function(obj) {
if (images) {
let imageLinks = images
.map(i => i.imageURL.urlList.find(p => p.includes(".jpeg?")))
.map(url => ({ url }));
.map((url, i) => {
if (obj.alwaysProxy) url = createStream({
service: "tiktok",
type: "proxy",
url,
filename: `${filenameBase}_photo_${i + 1}.jpg`
})
return {
type: "photo",
url
}
});
return {
picker: imageLinks,
@@ -117,4 +168,6 @@ export default async function(obj) {
headers: { cookie }
}
}
return { error: "fetch.empty" };
}

View File

@@ -1,4 +1,4 @@
import psl from "psl";
import psl from "@imput/psl";
const API_KEY = 'jrsCWX1XDuVxAFO4GkK147syAoN8BJZ5voz8tS80bPcj26Vc5Z';
const API_BASE = 'https://api-http2.tumblr.com';
@@ -22,7 +22,7 @@ export default async function(input) {
let { subdomain } = psl.parse(input.url.hostname);
if (subdomain?.includes('.')) {
return { error: ['ErrorBrokenLink', 'tumblr'] }
return { error: "link.unsupported" };
} else if (subdomain === 'www' || subdomain === 'at') {
subdomain = undefined
}
@@ -31,7 +31,7 @@ export default async function(input) {
const data = await request(domain, input.id);
const element = data?.response?.timeline?.elements?.[0];
if (!element) return { error: 'ErrorEmptyDownload' };
if (!element) return { error: "fetch.empty" };
const contents = [
...element.content,
@@ -53,7 +53,8 @@ export default async function(input) {
title: fileMetadata.title,
author: fileMetadata.artist
},
isAudioOnly: true
isAudioOnly: true,
bestAudio: "mp3",
}
}
@@ -66,5 +67,5 @@ export default async function(input) {
}
}
return { error: 'ErrorEmptyDownload' }
return { error: "link.unsupported" }
}

View File

@@ -1,11 +1,10 @@
import { env } from "../../config.js";
import { cleanString } from '../../sub/utils.js';
const gqlURL = "https://gql.twitch.tv/gql";
const clientIdHead = { "client-id": "kimne78kx3ncx6brgo4mv6wki5h1ko" };
export default async function (obj) {
let req_metadata = await fetch(gqlURL, {
const req_metadata = await fetch(gqlURL, {
method: "POST",
headers: clientIdHead,
body: JSON.stringify({
@@ -30,16 +29,19 @@ export default async function (obj) {
}`
})
}).then(r => r.status === 200 ? r.json() : false).catch(() => {});
if (!req_metadata) return { error: 'ErrorCouldntFetch' };
let clipMetadata = req_metadata.data.clip;
if (!req_metadata) return { error: "fetch.fail" };
if (clipMetadata.durationSeconds > env.durationLimit)
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
if (!clipMetadata.videoQualities || !clipMetadata.broadcaster)
return { error: 'ErrorEmptyDownload' };
const clipMetadata = req_metadata.data.clip;
let req_token = await fetch(gqlURL, {
if (clipMetadata.durationSeconds > env.durationLimit) {
return { error: "content.too_long" };
}
if (!clipMetadata.videoQualities || !clipMetadata.broadcaster) {
return { error: "fetch.empty" };
}
const req_token = await fetch(gqlURL, {
method: "POST",
headers: clientIdHead,
body: JSON.stringify([
@@ -58,25 +60,25 @@ export default async function (obj) {
])
}).then(r => r.status === 200 ? r.json() : false).catch(() => {});
if (!req_token) return { error: 'ErrorCouldntFetch' };
if (!req_token) return { error: "fetch.fail" };
let formats = clipMetadata.videoQualities;
let format = formats.find(f => f.quality === obj.quality) || formats[0];
const formats = clipMetadata.videoQualities;
const format = formats.find(f => f.quality === obj.quality) || formats[0];
return {
type: "bridge",
type: "proxy",
urls: `${format.sourceURL}?${new URLSearchParams({
sig: req_token[0].data.clip.playbackAccessToken.signature,
token: req_token[0].data.clip.playbackAccessToken.value
})}`,
fileMetadata: {
title: cleanString(clipMetadata.title.trim()),
title: clipMetadata.title.trim(),
artist: `Twitch Clip by @${clipMetadata.broadcaster.login}, clipped by @${clipMetadata.curator.login}`,
},
filenameAttributes: {
service: "twitch",
id: clipMetadata.id,
title: cleanString(clipMetadata.title.trim()),
title: clipMetadata.title.trim(),
author: `${clipMetadata.broadcaster.login}, clipped by ${clipMetadata.curator.login}`,
qualityLabel: `${format.quality}p`,
extension: 'mp4'

View File

@@ -0,0 +1,360 @@
import HLS from "hls-parser";
import { genericUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
import { getCookie, updateCookie } from "../cookie/manager.js";
const graphqlURL = 'https://api.x.com/graphql/4Siu98E55GquhG52zHdY5w/TweetDetail';
const tokenURL = 'https://api.x.com/1.1/guest/activate.json';
const tweetFeatures = JSON.stringify({"rweb_video_screen_enabled":false,"payments_enabled":false,"rweb_xchat_enabled":false,"profile_label_improvements_pcf_label_in_post_enabled":true,"rweb_tipjar_consumption_enabled":true,"verified_phone_label_enabled":false,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"premium_content_api_read_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"responsive_web_grok_analyze_button_fetch_trends_enabled":false,"responsive_web_grok_analyze_post_followups_enabled":true,"responsive_web_jetfuel_frame":true,"responsive_web_grok_share_attachment_enabled":true,"articles_preview_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"responsive_web_grok_show_grok_translated_post":false,"responsive_web_grok_analysis_button_from_backend":true,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"responsive_web_grok_image_annotation_enabled":true,"responsive_web_grok_imagine_annotation_enabled":true,"responsive_web_grok_community_note_auto_translation_is_enabled":false,"responsive_web_enhance_cards_enabled":false});
const tweetFieldToggles = JSON.stringify({"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false});
const commonHeaders = {
"user-agent": genericUserAgent,
"authorization": "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA",
"x-twitter-client-language": "en",
"x-twitter-active-user": "yes",
"accept-language": "en"
}
// fix all videos affected by the container bug in twitter muxer (took them over two weeks to fix it????)
const TWITTER_EPOCH = 1288834974657n;
const badContainerStart = new Date(1701446400000);
const badContainerEnd = new Date(1702605600000);
function needsFixing(media) {
const representativeId = media.source_status_id_str ?? media.id_str;
// syndication api doesn't have media ids in its response,
// so we just assume it's all good
if (!representativeId) return false;
const mediaTimestamp = new Date(
Number((BigInt(representativeId) >> 22n) + TWITTER_EPOCH)
);
return mediaTimestamp > badContainerStart && mediaTimestamp < badContainerEnd
}
function bestQuality(arr) {
return arr.filter(v => v.content_type === "video/mp4")
.reduce((a, b) => Number(a?.bitrate) > Number(b?.bitrate) ? a : b)
.url
}
let _cachedToken;
const getGuestToken = async (dispatcher, forceReload = false) => {
if (_cachedToken && !forceReload) {
return _cachedToken;
}
const tokenResponse = await fetch(tokenURL, {
method: 'POST',
headers: commonHeaders,
dispatcher
}).then(r => r.status === 200 && r.json()).catch(() => {})
if (tokenResponse?.guest_token) {
return _cachedToken = tokenResponse.guest_token
}
}
const requestSyndication = async(dispatcher, tweetId) => {
// thank you
// https://github.com/yt-dlp/yt-dlp/blob/05c8023a27dd37c49163c0498bf98e3e3c1cb4b9/yt_dlp/extractor/twitter.py#L1334
const token = (id) => ((Number(id) / 1e15) * Math.PI).toString(36).replace(/(0+|\.)/g, '');
const syndicationUrl = new URL("https://cdn.syndication.twimg.com/tweet-result");
syndicationUrl.searchParams.set("id", tweetId);
syndicationUrl.searchParams.set("token", token(tweetId));
const result = await fetch(syndicationUrl, {
headers: {
"user-agent": genericUserAgent
},
dispatcher
});
return result;
}
const requestTweet = async(dispatcher, tweetId, token, cookie) => {
const graphqlTweetURL = new URL(graphqlURL);
let headers = {
...commonHeaders,
'content-type': 'application/json',
'x-guest-token': token,
cookie: `guest_id=${encodeURIComponent(`v1:${token}`)}`
}
if (cookie) {
headers = {
...commonHeaders,
'content-type': 'application/json',
'X-Twitter-Auth-Type': 'OAuth2Session',
'x-csrf-token': cookie.values().ct0,
cookie
}
}
graphqlTweetURL.searchParams.set('variables',
JSON.stringify({
focalTweetId: tweetId,
with_rux_injections: false,
rankingMode: "Relevance",
includePromotedContent: true,
withCommunity: true,
withQuickPromoteEligibilityTweetFields: true,
withBirdwatchNotes: true,
withVoice: true
})
);
graphqlTweetURL.searchParams.set('features', tweetFeatures);
graphqlTweetURL.searchParams.set('fieldToggles', tweetFieldToggles);
let result = await fetch(graphqlTweetURL, { headers, dispatcher });
updateCookie(cookie, result.headers);
// we might have been missing the ct0 cookie, retry
if (result.status === 403 && result.headers.get('set-cookie')) {
const cookieValues = cookie?.values();
if (cookieValues?.ct0) {
result = await fetch(graphqlTweetURL, {
headers: {
...headers,
'x-csrf-token': cookieValues.ct0
},
dispatcher
});
}
}
return result
}
const parseCard = (cardOuter) => {
const card = JSON.parse(
(cardOuter?.legacy?.binding_values[0].value
|| cardOuter?.binding_values?.unified_card)?.string_value,
);
if (!["video_website", "image_website"].includes(card?.type)
|| !card?.media_entities
|| card?.component_objects?.media_1?.type !== "media") {
return;
}
const mediaId = card.component_objects?.media_1?.data?.id;
return [card.media_entities[mediaId]];
};
const extractGraphqlMedia = async (thread, dispatcher, id, guestToken, cookie) => {
const addInsn = thread?.data?.threaded_conversation_with_injections_v2?.instructions?.find(
insn => insn.type === 'TimelineAddEntries'
);
const tweetResult = addInsn?.entries?.find(
entry => entry.entryId === `tweet-${id}`
)?.content?.itemContent?.tweet_results?.result;
let tweetTypename = tweetResult?.__typename;
if (!tweetTypename) {
return { error: "fetch.empty" }
}
if (tweetTypename === "TweetUnavailable" || tweetTypename === "TweetTombstone") {
const reason = tweetResult?.result?.reason;
if (reason === 'Protected') {
return { error: "content.post.private" };
} else if (reason === "NsfwLoggedOut" || tweetResult?.tombstone?.text?.text?.startsWith('Age-restricted')) {
if (!cookie) {
return { error: "content.post.age" };
}
const tweet = await requestTweet(dispatcher, id, guestToken, cookie).then(t => t.json());
return extractGraphqlMedia(tweet, dispatcher, id, guestToken);
}
}
if (!["Tweet", "TweetWithVisibilityResults"].includes(tweetTypename)) {
return { error: "content.post.unavailable" }
}
let baseTweet = tweetResult.legacy,
repostedTweet = baseTweet?.retweeted_status_result?.result.legacy.extended_entities;
if (tweetTypename === "TweetWithVisibilityResults") {
baseTweet = tweetResult.tweet.legacy;
repostedTweet = baseTweet?.retweeted_status_result?.result.tweet.legacy.extended_entities;
}
if (tweetResult.card?.legacy?.binding_values?.length) {
return parseCard(tweetResult.card);
}
return (repostedTweet?.media || baseTweet?.extended_entities?.media);
}
export default async function({ id, index, toGif, dispatcher, alwaysProxy, subtitleLang }) {
const cookie = await getCookie('twitter');
let guestToken = await getGuestToken(dispatcher);
if (!guestToken) return { error: "fetch.fail" };
let tweet = await requestTweet(dispatcher, id, guestToken);
if ([403, 404, 429].includes(tweet.status)) {
// get new token & retry if old one expired
if ([403, 429].includes(tweet.status)) {
guestToken = await getGuestToken(dispatcher, true);
}
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
}
let media;
try {
tweet = await tweet.json();
media = await extractGraphqlMedia(tweet, dispatcher, id, guestToken, cookie);
} catch {}
// if graphql requests fail, then resort to tweet embed api
if (!media || 'error' in media) {
try {
tweet = await requestSyndication(dispatcher, id);
tweet = await tweet.json();
if (tweet?.card) {
media = parseCard(tweet.card);
}
} catch {}
media = tweet?.mediaDetails ?? media;
}
if (!media || 'error' in media) {
return { error: media?.error || "fetch.empty" };
}
// check if there's a video at given index (/video/<index>)
if (index >= 0 && index < media?.length) {
media = [media[index]]
}
const getFileExt = (url) => new URL(url).pathname.split(".", 2)[1];
const proxyMedia = (url, filename) => createStream({
service: "twitter",
type: "proxy",
url, filename,
});
const extractSubtitles = async (hlsUrl) => {
const mainHls = await fetch(hlsUrl).then(r => r.text()).catch(() => {});
if (!mainHls) return;
const subtitle = HLS.parse(mainHls)?.variants[0]?.subtitles?.find(
s => s.language.startsWith(subtitleLang)
);
if (!subtitle) return;
const subtitleUrl = new URL(subtitle.uri, hlsUrl).toString();
const subtitleHls = await fetch(subtitleUrl).then(r => r.text());
if (!subtitleHls) return;
const finalSubtitlePath = HLS.parse(subtitleHls)?.segments?.[0].uri;
if (!finalSubtitlePath) return;
const finalSubtitleUrl = new URL(finalSubtitlePath, hlsUrl).toString();
return {
url: finalSubtitleUrl,
language: subtitle.language,
};
}
switch (media?.length) {
case undefined:
case 0:
return {
error: "fetch.empty"
}
case 1:
const mediaItem = media[0];
if (mediaItem.type === "photo") {
return {
type: "proxy",
isPhoto: true,
filename: `twitter_${id}.${getFileExt(mediaItem.media_url_https)}`,
urls: `${mediaItem.media_url_https}?name=4096x4096`
}
}
let subtitles;
let fileMetadata;
if (mediaItem.type === "video" && subtitleLang) {
const hlsVariant = mediaItem.video_info?.variants?.find(
v => v.content_type === "application/x-mpegURL"
);
if (hlsVariant) {
const { url, language } = await extractSubtitles(hlsVariant.url) || {};
subtitles = url;
if (language) fileMetadata = { sublanguage: language };
}
}
return {
type: subtitles || needsFixing(mediaItem) ? "remux" : "proxy",
urls: bestQuality(mediaItem.video_info.variants),
filename: `twitter_${id}.mp4`,
audioFilename: `twitter_${id}_audio`,
isGif: mediaItem.type === "animated_gif",
subtitles,
fileMetadata,
}
default:
const proxyThumb = (url, i) =>
proxyMedia(url, `twitter_${id}_${i + 1}.${getFileExt(url)}`);
const picker = media.map((content, i) => {
if (content.type === "photo") {
let url = `${content.media_url_https}?name=4096x4096`;
let proxiedImage = proxyThumb(url, i);
if (alwaysProxy) url = proxiedImage;
return {
type: "photo",
url,
thumb: proxiedImage,
}
}
let url = bestQuality(content.video_info.variants);
const shouldRenderGif = content.type === "animated_gif" && toGif;
const videoFilename = `twitter_${id}_${i + 1}.${shouldRenderGif ? "gif" : "mp4"}`;
let type = "video";
if (shouldRenderGif) type = "gif";
if (needsFixing(content) || shouldRenderGif) {
url = createStream({
service: "twitter",
type: shouldRenderGif ? "gif" : "remux",
url,
filename: videoFilename,
})
} else if (alwaysProxy) {
url = proxyMedia(url, videoFilename);
}
return {
type,
url,
thumb: proxyThumb(content.media_url_https, i),
}
});
return { picker };
}
}

View File

@@ -0,0 +1,244 @@
import HLS from "hls-parser";
import { env } from "../../config.js";
import { merge } from '../../misc/utils.js';
import { getCookie } from "../cookie/manager.js";
const resolutionMatch = {
"3840": 2160,
"2732": 1440,
"2560": 1440,
"2048": 1080,
"1920": 1080,
"1366": 720,
"1280": 720,
"960": 480,
"640": 360,
"426": 240
}
const genericHeaders = {
Accept: 'application/vnd.vimeo.*+json; version=3.4.10',
'User-Agent': 'com.vimeo.android.videoapp (Google, Pixel 7a, google, Android 16/36 Version 11.8.1) Kotlin VimeoNetworking/3.12.0',
Authorization: 'Basic NzRmYTg5YjgxMWExY2JiNzUwZDg1MjhkMTYzZjQ4YWYyOGEyZGJlMTp4OGx2NFd3QnNvY1lkamI2UVZsdjdDYlNwSDUrdm50YzdNNThvWDcwN1JrenJGZC9tR1lReUNlRjRSVklZeWhYZVpRS0tBcU9YYzRoTGY2Z1dlVkJFYkdJc0dMRHpoZWFZbU0reDRqZ1dkZ1diZmdIdGUrNUM5RVBySlM0VG1qcw==',
'Accept-Language': 'en',
}
let bearer = '';
const getBearer = async (refresh = false) => {
const cookie = getCookie('vimeo_bearer')?.values?.()?.access_token;
if ((bearer || cookie) && !refresh) return bearer || cookie;
const oauthResponse = await fetch(
'https://api.vimeo.com/oauth/authorize/client',
{
method: 'POST',
body: new URLSearchParams({
scope: 'private public create edit delete interact upload purchased stats',
grant_type: 'client_credentials',
}).toString(),
headers: {
...genericHeaders,
'Content-Type': 'application/x-www-form-urlencoded',
}
}
)
.then(a => a.json())
.catch(() => {});
if (!oauthResponse || !oauthResponse.access_token) {
return;
}
return bearer = oauthResponse.access_token;
}
const requestApiInfo = (bearerToken, videoId, password) => {
if (password) {
videoId += `:${password}`
}
return fetch(
`https://api.vimeo.com/videos/${videoId}`,
{
headers: {
...genericHeaders,
Authorization: `Bearer ${bearerToken}`,
}
}
)
.then(a => a.json())
.catch(() => {});
}
const compareQuality = (rendition, requestedQuality) => {
const quality = parseInt(rendition);
return Math.abs(quality - requestedQuality);
}
const getDirectLink = async (data, quality, subtitleLang) => {
if (!data.files) return;
const match = data.files
.filter(f => f.rendition?.endsWith('p'))
.reduce((prev, next) => {
const delta = {
prev: compareQuality(prev.rendition, quality),
next: compareQuality(next.rendition, quality)
};
return delta.prev < delta.next ? prev : next;
});
if (!match) return;
let subtitles;
if (subtitleLang && data.config_url) {
const config = await fetch(data.config_url)
.then(r => r.json())
.catch(() => {});
if (config && config.request?.text_tracks?.length) {
subtitles = config.request.text_tracks.find(
t => t.lang.startsWith(subtitleLang)
);
subtitles = new URL(subtitles.url, "https://player.vimeo.com/").toString();
}
}
return {
urls: match.link,
subtitles,
filenameAttributes: {
resolution: `${match.width}x${match.height}`,
qualityLabel: match.rendition,
extension: "mp4"
},
bestAudio: "mp3",
}
}
const getHLS = async (configURL, obj) => {
if (!configURL) return;
const api = await fetch(configURL)
.then(r => r.json())
.catch(() => {});
if (!api) return { error: "fetch.fail" };
if (api.video?.duration > env.durationLimit) {
return { error: "content.too_long" };
}
const urlMasterHLS = api.request?.files?.hls?.cdns?.akfire_interconnect_quic?.url;
if (!urlMasterHLS) return { error: "fetch.fail" };
const masterHLS = await fetch(urlMasterHLS)
.then(r => r.text())
.catch(() => {});
if (!masterHLS) return { error: "fetch.fail" };
const variants = HLS.parse(masterHLS)?.variants?.sort(
(a, b) => Number(b.bandwidth) - Number(a.bandwidth)
);
if (!variants || variants.length === 0) return { error: "fetch.empty" };
let bestQuality;
if (obj.quality < resolutionMatch[variants[0]?.resolution?.width]) {
bestQuality = variants.find(v =>
(obj.quality === resolutionMatch[v.resolution.width])
);
}
if (!bestQuality) bestQuality = variants[0];
const expandLink = (path) => {
return new URL(path, urlMasterHLS).toString();
};
let urls = expandLink(bestQuality.uri);
const audioPath = bestQuality?.audio[0]?.uri;
if (audioPath) {
urls = [
urls,
expandLink(audioPath)
]
} else if (obj.isAudioOnly) {
return { error: "fetch.empty" };
}
return {
urls,
isHLS: true,
filenameAttributes: {
resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,
qualityLabel: `${resolutionMatch[bestQuality.resolution.width]}p`,
extension: "mp4"
},
bestAudio: "mp3",
}
}
export default async function(obj) {
let quality = obj.quality === "max" ? 9000 : Number(obj.quality);
if (quality < 240) quality = 240;
if (!quality || obj.isAudioOnly) quality = 9000;
const bearerToken = await getBearer();
if (!bearerToken) {
return { error: "fetch.fail" };
}
let info = await requestApiInfo(bearerToken, obj.id, obj.password);
let response;
// auth error, try to refresh the token
if (info?.error_code === 8003) {
const newBearer = await getBearer(true);
if (!newBearer) {
return { error: "fetch.fail" };
}
info = await requestApiInfo(newBearer, obj.id, obj.password);
}
// if there's still no info, then return a generic error
if (!info || info.error_code) {
return { error: "fetch.empty" };
}
if (obj.isAudioOnly) {
response = await getHLS(info.config_url, { ...obj, quality });
}
if (!response) response = await getDirectLink(info, quality, obj.subtitleLang);
if (!response) response = { error: "fetch.empty" };
if (response.error) {
return response;
}
const fileMetadata = {
title: info.name,
artist: info.user.name,
};
if (response.subtitles) {
fileMetadata.sublanguage = obj.subtitleLang;
}
return merge(
{
fileMetadata,
filenameAttributes: {
service: "vimeo",
id: obj.id,
title: fileMetadata.title,
author: fileMetadata.artist,
}
},
response
);
}

View File

@@ -0,0 +1,152 @@
import { env } from "../../config.js";
const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240", "144"];
const oauthUrl = "https://oauth.vk.com/oauth/get_anonym_token";
const apiUrl = "https://api.vk.com/method";
const clientId = "51552953";
const clientSecret = "qgr0yWwXCrsxA1jnRtRX";
// used in stream/shared.js for accessing media files
export const vkClientAgent = "com.vk.vkvideo.prod/822 (iPhone, iOS 16.7.7, iPhone10,4, Scale/2.0) SAK/1.119";
const cachedToken = {
token: "",
expiry: 0,
device_id: "",
};
const getToken = async () => {
if (cachedToken.expiry - 10 > Math.floor(new Date().getTime() / 1000)) {
return cachedToken.token;
}
const randomDeviceId = crypto.randomUUID().toUpperCase();
const anonymOauth = new URL(oauthUrl);
anonymOauth.searchParams.set("client_id", clientId);
anonymOauth.searchParams.set("client_secret", clientSecret);
anonymOauth.searchParams.set("device_id", randomDeviceId);
const oauthResponse = await fetch(anonymOauth.toString(), {
headers: {
"user-agent": vkClientAgent,
}
}).then(r => {
if (r.status === 200) {
return r.json();
}
});
if (!oauthResponse) return;
if (oauthResponse?.token && oauthResponse?.expired_at && typeof oauthResponse?.expired_at === "number") {
cachedToken.token = oauthResponse.token;
cachedToken.expiry = oauthResponse.expired_at;
cachedToken.device_id = randomDeviceId;
}
if (!cachedToken.token) return;
return cachedToken.token;
}
const getVideo = async (ownerId, videoId, accessKey) => {
const video = await fetch(`${apiUrl}/video.get`, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded; charset=utf-8",
"user-agent": vkClientAgent,
},
body: new URLSearchParams({
anonymous_token: cachedToken.token,
device_id: cachedToken.device_id,
lang: "en",
v: "5.244",
videos: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`
}).toString()
})
.then(r => {
if (r.status === 200) {
return r.json();
}
});
return video;
}
export default async function ({ ownerId, videoId, accessKey, quality, subtitleLang }) {
const token = await getToken();
if (!token) return { error: "fetch.fail" };
const videoGet = await getVideo(ownerId, videoId, accessKey);
if (!videoGet || !videoGet.response || videoGet.response.items.length !== 1) {
return { error: "fetch.empty" };
}
const video = videoGet.response.items[0];
if (video.restriction) {
const title = video.restriction.title;
if (title.endsWith("country") || title.endsWith("region.")) {
return { error: "content.video.region" };
}
if (title === "Processing video") {
return { error: "fetch.empty" };
}
return { error: "content.video.unavailable" };
}
if (!video.files || !video.duration) {
return { error: "fetch.fail" };
}
if (video.duration > env.durationLimit) {
return { error: "content.too_long" };
}
const userQuality = quality === "max" ? resolutions[0] : quality;
let pickedQuality;
for (const resolution of resolutions) {
if (video.files[`mp4_${resolution}`] && +resolution <= +userQuality) {
pickedQuality = resolution;
break
}
}
const url = video.files[`mp4_${pickedQuality}`];
if (!url) return { error: "fetch.fail" };
const fileMetadata = {
title: video.title.trim(),
}
let subtitles;
if (subtitleLang && video.subtitles?.length) {
const subtitle = video.subtitles.find(
s => s.title.endsWith(".vtt") && s.lang.startsWith(subtitleLang)
);
if (subtitle) {
subtitles = subtitle.url;
fileMetadata.sublanguage = subtitleLang;
}
}
return {
urls: url,
subtitles,
fileMetadata,
filenameAttributes: {
service: "vk",
id: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`,
title: fileMetadata.title,
resolution: `${pickedQuality}p`,
qualityLabel: `${pickedQuality}p`,
extension: "mp4"
}
}
}

View File

@@ -0,0 +1,109 @@
import { resolveRedirectingURL } from "../url.js";
import { genericUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
const https = (url) => {
return url.replace(/^http:/i, 'https:');
}
export default async function ({ id, token, shareType, shareId, h265, isAudioOnly, dispatcher }) {
let noteId = id;
let xsecToken = token;
if (!noteId) {
const patternMatch = await resolveRedirectingURL(
`https://xhslink.com/${shareType}/${shareId}`,
dispatcher
);
noteId = patternMatch?.id;
xsecToken = patternMatch?.token;
}
if (!noteId || !xsecToken) return { error: "fetch.short_link" };
const res = await fetch(`https://www.xiaohongshu.com/explore/${noteId}?xsec_token=${xsecToken}`, {
headers: {
"user-agent": genericUserAgent,
},
dispatcher,
});
const html = await res.text();
let note;
try {
const initialState = html
.split('<script>window.__INITIAL_STATE__=')[1]
.split('</script>')[0]
.replace(/:\s*undefined/g, ":null");
const data = JSON.parse(initialState);
const noteInfo = data?.note?.noteDetailMap;
if (!noteInfo) throw "no note detail map";
const currentNote = noteInfo[noteId];
if (!currentNote) throw "no current note in detail map";
note = currentNote.note;
} catch {}
if (!note) return { error: "fetch.empty" };
const video = note.video;
const images = note.imageList;
const filenameBase = `xiaohongshu_${noteId}`;
if (video) {
const videoFilename = `${filenameBase}.mp4`;
const audioFilename = `${filenameBase}_audio`;
let videoURL;
if (h265 && !isAudioOnly && video.consumer?.originVideoKey) {
videoURL = `https://sns-video-bd.xhscdn.com/${video.consumer.originVideoKey}`;
} else {
const h264Streams = video.media?.stream?.h264;
if (h264Streams?.length) {
videoURL = h264Streams.reduce((a, b) => Number(a?.videoBitrate) > Number(b?.videoBitrate) ? a : b).masterUrl;
}
}
if (!videoURL) return { error: "fetch.empty" };
return {
urls: https(videoURL),
filename: videoFilename,
audioFilename: audioFilename,
}
}
if (!images || images.length === 0) {
return { error: "fetch.empty" };
}
if (images.length === 1) {
return {
isPhoto: true,
urls: https(images[0].urlDefault),
filename: `${filenameBase}.jpg`,
}
}
const picker = images.map((image, i) => {
return {
type: "photo",
url: createStream({
service: "xiaohongshu",
type: "proxy",
url: https(image.urlDefault),
filename: `${filenameBase}_${i + 1}.jpg`,
})
}
});
return { picker };
}

View File

@@ -0,0 +1,636 @@
import HLS from "hls-parser";
import { fetch, Request } from "undici";
import { Innertube, Platform, Session } from "youtubei.js";
import { env } from "../../config.js";
import { getCookie } from "../cookie/manager.js";
import { getYouTubeSession } from "../helpers/youtube-session.js";
// https://github.com/LuanRT/YouTube.js/pull/1052
Platform.shim.eval = async (data, env) => {
const properties = [];
if (env.n) {
properties.push(`n: exportedVars.nFunction("${env.n}")`)
}
if (env.sig) {
properties.push(`sig: exportedVars.sigFunction("${env.sig}")`)
}
const code = `${data.output}\nreturn { ${properties.join(', ')} }`;
return new Function(code)();
}
const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
let innertube, lastRefreshedAt;
const codecList = {
h264: {
videoCodec: "avc1",
audioCodec: "mp4a",
container: "mp4"
},
av1: {
videoCodec: "av01",
audioCodec: "opus",
container: "webm"
},
vp9: {
videoCodec: "vp9",
audioCodec: "opus",
container: "webm"
}
}
const hlsCodecList = {
h264: {
videoCodec: "avc1",
audioCodec: "mp4a",
container: "mp4"
},
vp9: {
videoCodec: "vp09",
audioCodec: "mp4a",
container: "webm"
}
}
const clientsWithNoCipher = ['IOS', 'ANDROID', 'YTSTUDIO_ANDROID', 'YTMUSIC_ANDROID'];
const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320];
const cloneInnertube = async (customFetch, useSession) => {
const shouldRefreshPlayer = lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date();
const rawCookie = getCookie('youtube');
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) {
innertube = await Innertube.create({
fetch: customFetch,
retrieve_player,
cookie,
po_token: useSession ? sessionTokens?.potoken : undefined,
visitor_data: useSession ? sessionTokens?.visitor_data : undefined,
});
lastRefreshedAt = +new Date();
}
const session = new Session(
innertube.session.context,
innertube.session.api_key,
innertube.session.api_version,
innertube.session.account_index,
innertube.session.config_data,
innertube.session.player,
cookie,
customFetch ?? innertube.session.http.fetch,
innertube.session.cache,
sessionTokens?.potoken
);
const yt = new Innertube(session);
return yt;
}
const getHlsVariants = async (hlsManifest, dispatcher) => {
if (!hlsManifest) {
return { error: "youtube.no_hls_streams" };
}
const fetchedHlsManifest =
await fetch(hlsManifest, { dispatcher })
.then(r => r.status === 200 ? r.text() : undefined)
.catch(() => {});
if (!fetchedHlsManifest) {
return { error: "youtube.no_hls_streams" };
}
const variants = HLS.parse(fetchedHlsManifest).variants.sort(
(a, b) => Number(b.bandwidth) - Number(a.bandwidth)
);
if (!variants || variants.length === 0) {
return { error: "youtube.no_hls_streams" };
}
return variants;
}
const getSubtitles = async (info, dispatcher, subtitleLang) => {
const preferredCap = info.captions.caption_tracks.find(caption =>
caption.kind !== 'asr' && caption.language_code.startsWith(subtitleLang)
);
const captionsUrl = preferredCap?.base_url;
if (!captionsUrl) return;
if (!captionsUrl.includes("exp=xpe")) {
let url = new URL(captionsUrl);
url.searchParams.set('fmt', 'vtt');
return {
url: url.toString(),
language: preferredCap.language_code,
}
}
// if we have exp=xpe in the url, then captions are
// locked down and can't be accessed without a yummy potoken,
// so instead we just use subtitles from HLS
const hlsVariants = await getHlsVariants(
info.streaming_data.hls_manifest_url,
dispatcher
);
if (hlsVariants?.error) return;
// all variants usually have the same set of subtitles
const hlsSubtitles = hlsVariants[0]?.subtitles;
if (!hlsSubtitles?.length) return;
const preferredHls = hlsSubtitles.find(
subtitle => subtitle.language.startsWith(subtitleLang)
);
if (!preferredHls) return;
const fetchedHlsSubs =
await fetch(preferredHls.uri, { dispatcher })
.then(r => r.status === 200 ? r.text() : undefined)
.catch(() => {});
const parsedSubs = HLS.parse(fetchedHlsSubs);
if (!parsedSubs) return;
return {
url: parsedSubs.segments[0]?.uri,
language: preferredHls.language,
}
}
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.codec === "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
let useSession =
env.ytSessionServer && (
(
!useHLS
&& innertubeClient === "IOS"
&& (
(quality > 1080 && o.codec !== "h264")
|| (quality > 1080 && o.codec !== "vp9")
)
)
);
// we can get subtitles reliably only from the iOS client
if (o.subtitleLang) {
innertubeClient = "IOS";
useSession = false;
}
if (useSession) {
innertubeClient = env.ytSessionInnertubeClient || "WEB_EMBEDDED";
}
let yt;
try {
yt = await cloneInnertube(
(input, init) => {
const url = typeof input === 'string'
? new URL(input)
: input instanceof URL
? input
: new URL(input.url);
const request = new Request(
url,
input instanceof Platform.shim.Request
? input : undefined
);
return fetch(request, {
...init,
dispatcher: o.dispatcher
});
},
useSession
);
} catch (e) {
if (e === "no_session_tokens") {
return { error: "youtube.no_session_tokens" };
} else if (e.message?.endsWith("decipher algorithm")) {
return { error: "youtube.decipher" }
} else if (e.message?.includes("refresh access token")) {
return { error: "youtube.token_expired" }
} else throw e;
}
let info;
try {
info = await yt.getBasicInfo(o.id, { client: innertubeClient });
} catch (e) {
if (e?.info) {
let errorInfo;
try { errorInfo = JSON.parse(e?.info); } catch {}
if (errorInfo?.reason === "This video is private") {
return { error: "content.video.private" };
}
if (["INVALID_ARGUMENT", "UNAUTHENTICATED"].includes(errorInfo?.error?.status)) {
return { error: "youtube.api_error" };
}
}
if (e?.message === "This video is unavailable") {
return { error: "content.video.unavailable" };
}
return { error: "fetch.fail" };
}
if (!info) return { error: "fetch.fail" };
const playability = info.playability_status;
const basicInfo = info.basic_info;
switch (playability.status) {
case "LOGIN_REQUIRED":
if (playability.reason.endsWith("bot")) {
return { error: "youtube.login" }
}
if (playability.reason.endsWith("age") || playability.reason.endsWith("inappropriate for some users.")) {
return { error: "content.video.age" }
}
if (playability?.error_screen?.reason?.text === "Private video") {
return { error: "content.video.private" }
}
break;
case "UNPLAYABLE":
if (playability?.reason?.endsWith("request limit.")) {
return { error: "fetch.rate" }
}
if (playability?.error_screen?.subreason?.text?.endsWith("in your country")) {
return { error: "content.video.region" }
}
if (playability?.error_screen?.reason?.text === "Private video") {
return { error: "content.video.private" }
}
break;
case "AGE_VERIFICATION_REQUIRED":
return { error: "content.video.age" };
}
if (playability.status !== "OK") {
return { error: "content.video.unavailable" };
}
if (basicInfo.is_live) {
return { error: "content.video.live" };
}
if (basicInfo.duration > env.durationLimit) {
return { error: "content.too_long" };
}
// return a critical error if returned video is "Video Not Available"
// or a similar stub by youtube
if (basicInfo.id !== o.id) {
return {
error: "fetch.fail",
critical: true
}
}
const normalizeQuality = res => {
const shortestSide = Math.min(res.height, res.width);
return videoQualities.find(qual => qual >= shortestSide);
}
let video, audio, subtitles, dubbedLanguage,
codec = o.codec || "h264", itag = o.itag;
if (useHLS) {
const variants = await getHlsVariants(
info.streaming_data.hls_manifest_url,
o.dispatcher
);
if (variants?.error) return variants;
const matchHlsCodec = codecs => (
codecs.includes(hlsCodecList[codec].videoCodec)
);
const best = variants.find(i => matchHlsCodec(i.codecs));
const preferred = variants.find(i =>
matchHlsCodec(i.codecs) && normalizeQuality(i.resolution) === quality
);
let selected = preferred || best;
if (!selected) {
codec = "h264";
selected = variants.find(i => matchHlsCodec(i.codecs));
}
if (!selected) {
return { error: "youtube.no_matching_format" };
}
audio = selected.audio.find(i => i.isDefault);
// some videos (mainly those with AI dubs) don't have any tracks marked as default
// why? god knows, but we assume that a default track is marked as such in the title
if (!audio) {
audio = selected.audio.find(i => i.name.endsWith("original"));
}
if (o.dubLang) {
const dubbedAudio = selected.audio.find(i =>
i.language?.startsWith(o.dubLang)
);
if (dubbedAudio && !dubbedAudio.isDefault) {
dubbedLanguage = dubbedAudio.language;
audio = dubbedAudio;
}
}
selected.audio = [];
selected.subtitles = [];
video = selected;
} else {
// i miss typescript so bad
const sorted_formats = {
h264: {
video: [],
audio: [],
bestVideo: undefined,
bestAudio: undefined,
},
vp9: {
video: [],
audio: [],
bestVideo: undefined,
bestAudio: undefined,
},
av1: {
video: [],
audio: [],
bestVideo: undefined,
bestAudio: undefined,
},
}
const checkFormat = (format, pCodec) => format.content_length &&
(format.mime_type.includes(codecList[pCodec].videoCodec)
|| format.mime_type.includes(codecList[pCodec].audioCodec));
// sort formats & weed out bad ones
info.streaming_data.adaptive_formats.sort((a, b) =>
Number(b.bitrate) - Number(a.bitrate)
).forEach(format => {
Object.keys(codecList).forEach(yCodec => {
const matchingItag = slot => !itag?.[slot] || itag[slot] === format.itag;
const sorted = sorted_formats[yCodec];
const goodFormat = checkFormat(format, yCodec);
if (!goodFormat) return;
if (format.has_video && matchingItag('video')) {
sorted.video.push(format);
if (!sorted.bestVideo)
sorted.bestVideo = format;
}
if (format.has_audio && matchingItag('audio')) {
sorted.audio.push(format);
if (!sorted.bestAudio)
sorted.bestAudio = format;
}
})
});
const noBestMedia = () => {
const vid = sorted_formats[codec]?.bestVideo;
const aud = sorted_formats[codec]?.bestAudio;
return (!vid && !o.isAudioOnly) || (!aud && o.isAudioOnly)
};
if (noBestMedia()) {
if (codec === "av1") codec = "vp9";
else if (codec === "vp9") codec = "av1";
// if there's no higher quality fallback, then use h264
if (noBestMedia()) codec = "h264";
}
// if there's no proper combo of av1, vp9, or h264, then give up
if (noBestMedia()) {
return { error: "youtube.no_matching_format" };
}
audio = sorted_formats[codec].bestAudio;
if (audio?.audio_track && !audio?.is_original) {
audio = sorted_formats[codec].audio.find(i =>
i?.is_original
);
}
if (o.dubLang) {
const dubbedAudio = sorted_formats[codec].audio.find(i =>
i.language?.startsWith(o.dubLang) && i.audio_track
);
if (dubbedAudio && !dubbedAudio?.is_original) {
audio = dubbedAudio;
dubbedLanguage = dubbedAudio.language;
}
}
if (!o.isAudioOnly) {
const qual = (i) => {
return normalizeQuality({
width: i.width,
height: i.height,
})
}
const bestQuality = qual(sorted_formats[codec].bestVideo);
const useBestQuality = quality >= bestQuality;
video = useBestQuality
? sorted_formats[codec].bestVideo
: sorted_formats[codec].video.find(i => qual(i) === quality);
if (!video) video = sorted_formats[codec].bestVideo;
}
if (o.subtitleLang && !o.isAudioOnly && info.captions?.caption_tracks?.length) {
const videoSubtitles = await getSubtitles(info, o.dispatcher, o.subtitleLang);
if (videoSubtitles) {
subtitles = videoSubtitles;
}
}
}
if (video?.drm_families || audio?.drm_families) {
return { error: "youtube.drm" };
}
const fileMetadata = {
title: basicInfo.title.trim(),
artist: basicInfo.author.replace("- Topic", "").trim()
}
if (basicInfo?.short_description?.startsWith("Provided to YouTube by")) {
const descItems = basicInfo.short_description.split("\n\n", 5);
if (descItems.length === 5) {
fileMetadata.album = descItems[2];
fileMetadata.copyright = descItems[3];
if (descItems[4].startsWith("Released on:")) {
fileMetadata.date = descItems[4].replace("Released on: ", '').trim();
}
}
}
if (subtitles) {
fileMetadata.sublanguage = subtitles.language;
}
const filenameAttributes = {
service: "youtube",
id: o.id,
title: fileMetadata.title,
author: fileMetadata.artist,
youtubeDubName: dubbedLanguage || false,
}
itag = {
video: video?.itag,
audio: audio?.itag
};
const originalRequest = {
...o,
dispatcher: undefined,
itag,
innertubeClient
};
if (audio && o.isAudioOnly) {
let bestAudio = codec === "h264" ? "m4a" : "opus";
let urls = audio.url;
if (useHLS) {
bestAudio = "mp3";
urls = audio.uri;
}
if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {
urls = await audio.decipher(innertube.session.player);
}
let cover = `https://i.ytimg.com/vi/${o.id}/maxresdefault.jpg`;
const testMaxCover = await fetch(cover, { dispatcher: o.dispatcher })
.then(r => r.status === 200)
.catch(() => {});
if (!testMaxCover) {
cover = basicInfo.thumbnail?.[0]?.url;
}
return {
type: "audio",
isAudioOnly: true,
urls,
filenameAttributes,
fileMetadata,
bestAudio,
isHLS: useHLS,
originalRequest,
cover,
cropCover: basicInfo.author.endsWith("- Topic"),
}
}
if (video && audio) {
let resolution;
if (useHLS) {
resolution = normalizeQuality(video.resolution);
filenameAttributes.resolution = `${video.resolution.width}x${video.resolution.height}`;
filenameAttributes.extension = o.container === "auto" ? hlsCodecList[codec].container : o.container;
video = video.uri;
audio = audio.uri;
} else {
resolution = normalizeQuality({
width: video.width,
height: video.height,
});
filenameAttributes.resolution = `${video.width}x${video.height}`;
filenameAttributes.extension = o.container === "auto" ? codecList[codec].container : o.container;
if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {
video = await video.decipher(innertube.session.player);
audio = await audio.decipher(innertube.session.player);
} else {
video = video.url;
audio = audio.url;
}
}
filenameAttributes.qualityLabel = `${resolution}p`;
filenameAttributes.youtubeFormat = codec;
return {
type: "merge",
urls: [
video,
audio,
],
subtitles: subtitles?.url,
filenameAttributes,
fileMetadata,
isHLS: useHLS,
originalRequest
}
}
return { error: "youtube.no_matching_format" };
}

View File

@@ -1,6 +1,10 @@
import { services } from "../config.js";
import psl from "@imput/psl";
import { strict as assert } from "node:assert";
import psl from "psl";
import { env } from "../config.js";
import { services } from "./service-config.js";
import { getRedirectingURL } from "../misc/utils.js";
import { friendlyServiceName } from "./service-alias.js";
function aliasURL(url) {
assert(url instanceof URL);
@@ -13,7 +17,7 @@ function aliasURL(url) {
if (url.pathname.startsWith('/live/') || url.pathname.startsWith('/shorts/')) {
url.pathname = '/watch';
// parts := ['', 'live' || 'shorts', id, ...rest]
url.search = `?v=${encodeURIComponent(parts[2])}`
url.search = `?v=${encodeURIComponent(parts[2])}`;
}
break;
@@ -39,7 +43,7 @@ function aliasURL(url) {
case "fixvx":
case "x":
if (services.twitter.altDomains.includes(url.hostname)) {
url.hostname = 'twitter.com'
url.hostname = 'twitter.com';
}
break;
@@ -54,24 +58,26 @@ function aliasURL(url) {
url = new URL(`https://bilibili.com/_tv${url.pathname}`);
}
break;
case "b23":
if (url.hostname === 'b23.tv' && parts.length === 2) {
url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`)
url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`);
}
break;
case "dai":
if (url.hostname === 'dai.ly' && parts.length === 2) {
url = new URL(`https://dailymotion.com/video/${parts[1]}`)
url = new URL(`https://dailymotion.com/video/${parts[1]}`);
}
break;
case "facebook":
case "fb":
if (url.searchParams.get('v')) {
url = new URL(`https://web.facebook.com/user/videos/${url.searchParams.get('v')}`)
url = new URL(`https://web.facebook.com/user/videos/${url.searchParams.get('v')}`);
}
if (url.hostname === 'fb.watch') {
url = new URL(`https://web.facebook.com/_shortLink/${parts[1]}`)
url = new URL(`https://web.facebook.com/_shortLink/${parts[1]}`);
}
break;
@@ -80,9 +86,40 @@ function aliasURL(url) {
url.hostname = 'instagram.com';
}
break;
case "vk":
case "vkvideo":
if (services.vk.altDomains.includes(url.hostname)) {
url.hostname = 'vk.com';
}
if (url.searchParams.get('z')) {
url = new URL(`https://vk.com/${url.searchParams.get('z')}`);
}
break;
case "xhslink":
if (url.hostname === 'xhslink.com' && parts.length === 3) {
url = new URL(`https://www.xiaohongshu.com/${parts[1]}/${parts[2]}`);
}
break;
case "loom":
const idPart = parts[parts.length - 1];
if (idPart.length > 32) {
url.pathname = `/share/${idPart.slice(-32)}`;
}
break;
case "redd":
/* reddit short video links can be treated by changing https://v.redd.it/<id>
to https://reddit.com/video/<id>.*/
if (url.hostname === "v.redd.it" && parts.length === 2) {
url = new URL(`https://www.reddit.com/video/${parts[1]}`);
}
break;
}
return url
return url;
}
function cleanURL(url) {
@@ -102,31 +139,42 @@ function cleanURL(url) {
break;
case "vk":
if (url.pathname.includes('/clip') && url.searchParams.get('z')) {
limitQuery('z')
limitQuery('z');
}
break;
case "youtube":
if (url.searchParams.get('v')) {
limitQuery('v')
limitQuery('v');
}
break;
case "bilibili":
case "rutube":
if (url.searchParams.get('p')) {
limitQuery('p')
limitQuery('p');
}
break;
case "twitter":
if (url.searchParams.get('post_id')) {
limitQuery('post_id');
}
break;
case "xiaohongshu":
if (url.searchParams.get('xsec_token')) {
limitQuery('xsec_token');
}
break;
}
if (stripQuery) {
url.search = ''
url.search = '';
}
url.username = url.password = url.port = url.hash = ''
url.username = url.password = url.port = url.hash = '';
if (url.pathname.endsWith('/'))
url.pathname = url.pathname.slice(0, -1);
return url
return url;
}
function getHostIfValid(url) {
@@ -152,15 +200,24 @@ export function normalizeURL(url) {
);
}
export function extract(url) {
export function extract(url, enabledServices = env.enabledServices) {
if (!(url instanceof URL)) {
url = new URL(url);
}
const host = getHostIfValid(url);
if (!host || !services[host].enabled) {
return null;
if (!host) {
return { error: "link.invalid" };
}
if (!enabledServices.has(host)) {
// show a different message when youtube is disabled on official instances
// as it only happens when shit hits the fan
if (new URL(env.apiURL).hostname.endsWith(".imput.net") && host === "youtube") {
return { error: "youtube.temporary_disabled" };
}
return { error: "service.disabled" };
}
let patternMatch;
@@ -175,8 +232,27 @@ export function extract(url) {
}
if (!patternMatch) {
return null;
return {
error: "link.unsupported",
context: {
service: friendlyServiceName(host),
}
};
}
return { host, patternMatch };
}
}
export async function resolveRedirectingURL(url, dispatcher, headers) {
const originalService = getHostIfValid(normalizeURL(url));
if (!originalService) return;
const canonicalURL = await getRedirectingURL(url, dispatcher, headers);
if (!canonicalURL) return;
const { host, patternMatch } = extract(normalizeURL(canonicalURL));
if (host === originalService) {
return patternMatch;
}
}

View File

@@ -0,0 +1,266 @@
import { env } from "../config.js";
import { Green, Yellow } from "../misc/console-text.js";
import ip from "ipaddr.js";
import * as cluster from "../misc/cluster.js";
import { FileWatcher } from "../misc/file-watcher.js";
// this function is a modified variation of code
// from https://stackoverflow.com/a/32402438/14855621
const generateWildcardRegex = rule => {
var escapeRegex = (str) => str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
return new RegExp("^" + rule.split("*").map(escapeRegex).join(".*") + "$");
}
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
let keys = {}, reader = null;
const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit', 'allowedServices']);
/* Expected format pseudotype:
** type KeyFileContents = Record<
** UUIDv4String,
** {
** name?: string,
** limit?: number | "unlimited",
** ips?: CIDRString[],
** userAgents?: string[],
** allowedServices?: "all" | string[],
** }
** >;
*/
const validateKeys = (input) => {
if (typeof input !== 'object' || input === null) {
throw "input is not an object";
}
if (Object.keys(input).some(x => !UUID_REGEX.test(x))) {
throw "key file contains invalid key(s)";
}
Object.values(input).forEach(details => {
if (typeof details !== 'object' || details === null) {
throw "some key(s) are incorrectly configured";
}
const unexpected_key = Object.keys(details).find(k => !ALLOWED_KEYS.has(k));
if (unexpected_key) {
throw "detail object contains unexpected key: " + unexpected_key;
}
if (details.limit && details.limit !== 'unlimited') {
if (typeof details.limit !== 'number')
throw "detail object contains invalid limit (not a number)";
else if (details.limit < 1)
throw "detail object contains invalid limit (not a positive number)";
}
if (details.ips) {
if (!Array.isArray(details.ips))
throw "details object contains value for `ips` which is not an array";
const invalid_ip = details.ips.find(
addr => typeof addr !== 'string' || (!ip.isValidCIDR(addr) && !ip.isValid(addr))
);
if (invalid_ip) {
throw "`ips` in details contains an invalid IP or CIDR range: " + invalid_ip;
}
}
if (details.userAgents) {
if (!Array.isArray(details.userAgents))
throw "details object contains value for `userAgents` which is not an array";
const invalid_ua = details.userAgents.find(ua => typeof ua !== 'string');
if (invalid_ua) {
throw "`userAgents` in details contains an invalid user agent: " + invalid_ua;
}
}
if (details.allowedServices) {
if (Array.isArray(details.allowedServices)) {
const invalid_services = details.allowedServices.some(
service => !env.allServices.has(service)
);
if (invalid_services) {
throw "`allowedServices` in details contains an invalid service";
}
} else if (details.allowedServices !== "all") {
throw "details object contains value for `allowedServices` which is not an array or `all`";
}
}
});
}
const formatKeys = (keyData) => {
const formatted = {};
for (let key in keyData) {
const data = keyData[key];
key = key.toLowerCase();
formatted[key] = {};
if (data.limit) {
if (data.limit === "unlimited") {
data.limit = Infinity;
}
formatted[key].limit = data.limit;
}
if (data.ips) {
formatted[key].ips = data.ips.map(addr => {
if (ip.isValid(addr)) {
const parsed = ip.parse(addr);
const range = parsed.kind() === 'ipv6' ? 128 : 32;
return [ parsed, range ];
}
return ip.parseCIDR(addr);
});
}
if (data.userAgents) {
formatted[key].userAgents = data.userAgents.map(generateWildcardRegex);
}
if (data.allowedServices) {
if (Array.isArray(data.allowedServices)) {
formatted[key].allowedServices = new Set(data.allowedServices);
} else {
formatted[key].allowedServices = data.allowedServices;
}
}
}
return formatted;
}
const updateKeys = (newKeys) => {
validateKeys(newKeys);
cluster.broadcast({ api_keys: newKeys });
keys = formatKeys(newKeys);
}
const loadRemoteKeys = async (source) => {
updateKeys(
await fetch(source).then(a => a.json())
);
}
const loadLocalKeys = async () => {
updateKeys(
JSON.parse(await reader.read())
);
}
const wrapLoad = (url, initial = false) => {
let load = loadRemoteKeys.bind(null, url);
if (url.protocol === 'file:') {
if (initial) {
reader = FileWatcher.fromFileProtocol(url);
reader.on('file-updated', () => wrapLoad(url));
}
load = loadLocalKeys;
}
load().then(() => {
if (initial || reader) {
console.log(`${Green('[✓]')} api keys loaded successfully!`)
}
})
.catch((e) => {
console.error(`${Yellow('[!]')} Failed loading API keys at ${new Date().toISOString()}.`);
console.error('Error:', e);
})
}
const err = (reason) => ({ success: false, error: reason });
export const validateAuthorization = (req) => {
const authHeader = req.get('Authorization');
if (typeof authHeader !== 'string') {
return err("missing");
}
const [ authType, keyString ] = authHeader.split(' ', 2);
if (authType.toLowerCase() !== 'api-key') {
return err("not_api_key");
}
if (!UUID_REGEX.test(keyString) || `${authType} ${keyString}` !== authHeader) {
return err("invalid");
}
const matchingKey = keys[keyString.toLowerCase()];
if (!matchingKey) {
return err("not_found");
}
if (matchingKey.ips) {
let addr;
try {
addr = ip.parse(req.ip);
} catch {
return err("invalid_ip");
}
const ip_allowed = matchingKey.ips.some(
([ allowed, size ]) => {
return addr.kind() === allowed.kind()
&& addr.match(allowed, size);
}
);
if (!ip_allowed) {
return err("ip_not_allowed");
}
}
if (matchingKey.userAgents) {
const userAgent = req.get('User-Agent');
if (!matchingKey.userAgents.some(regex => regex.test(userAgent))) {
return err("ua_not_allowed");
}
}
req.rateLimitKey = keyString.toLowerCase();
req.rateLimitMax = matchingKey.limit;
return { success: true };
}
export const setup = (url) => {
if (cluster.isPrimary) {
wrapLoad(url, true);
if (env.keyReloadInterval > 0 && url.protocol !== 'file:') {
setInterval(() => wrapLoad(url), env.keyReloadInterval * 1000);
}
} else if (cluster.isWorker) {
process.on('message', (message) => {
if ('api_keys' in message) {
updateKeys(message.api_keys);
}
});
}
}
export const getAllowedServices = (key) => {
if (typeof key !== "string") return;
const allowedServices = keys[key.toLowerCase()]?.allowedServices;
if (!allowedServices) return;
if (allowedServices === "all") {
return env.allServices;
}
return allowedServices;
}

66
api/src/security/jwt.js Normal file
View File

@@ -0,0 +1,66 @@
import { nanoid } from "nanoid";
import { createHmac } from "crypto";
import { env } from "../config.js";
const toBase64URL = (b) => Buffer.from(b).toString("base64url");
const fromBase64URL = (b) => Buffer.from(b, "base64url").toString();
const makeHmac = (data) => {
return createHmac("sha256", env.jwtSecret)
.update(data)
.digest("base64url");
}
const sign = (header, payload) =>
makeHmac(`${header}.${payload}`);
const getIPHash = (ip) =>
makeHmac(ip).slice(0, 8);
const generate = (ip) => {
const exp = Math.floor(new Date().getTime() / 1000) + env.jwtLifetime;
const header = toBase64URL(JSON.stringify({
alg: "HS256",
typ: "JWT"
}));
const payload = toBase64URL(JSON.stringify({
jti: nanoid(8),
sub: getIPHash(ip),
exp,
}));
const signature = sign(header, payload);
return {
token: `${header}.${payload}.${signature}`,
exp: env.jwtLifetime - 2,
};
}
const verify = (jwt, ip) => {
const [header, payload, signature] = jwt.split(".", 3);
const timestamp = Math.floor(new Date().getTime() / 1000);
if ([header, payload, signature].join('.') !== jwt) {
return false;
}
const verifySignature = sign(header, payload);
if (verifySignature !== signature) {
return false;
}
const data = JSON.parse(fromBase64URL(payload));
return getIPHash(ip) === data.sub
&& timestamp <= data.exp;
}
export default {
generate,
verify,
}

View File

@@ -0,0 +1,62 @@
import cluster from "node:cluster";
import { createHmac, randomBytes } from "node:crypto";
const generateSalt = () => {
if (cluster.isPrimary)
return randomBytes(64);
return null;
}
let rateSalt = generateSalt();
let streamSalt = generateSalt();
export const syncSecrets = () => {
return new Promise((resolve, reject) => {
if (cluster.isPrimary) {
let remaining = Object.values(cluster.workers).length;
const handleReady = (worker, m) => {
if (m.ready)
worker.send({ rateSalt, streamSalt });
if (!--remaining)
resolve();
}
for (const worker of Object.values(cluster.workers)) {
worker.once(
'message',
(m) => handleReady(worker, m)
);
}
} else if (cluster.isWorker) {
if (rateSalt || streamSalt)
return reject();
process.send({ ready: true });
process.once('message', (message) => {
if (rateSalt || streamSalt)
return reject();
if (message.rateSalt && message.streamSalt) {
streamSalt = Buffer.from(message.streamSalt);
rateSalt = Buffer.from(message.rateSalt);
resolve();
}
});
} else reject();
});
}
export const hashHmac = (value, type) => {
let salt;
if (type === 'rate')
salt = rateSalt;
else if (type === 'stream')
salt = streamSalt;
else
throw "unknown salt";
return createHmac("sha256", salt).update(value).digest();
}

View File

@@ -0,0 +1,19 @@
import { env } from "../config.js";
export const verifyTurnstileToken = async (turnstileResponse, ip) => {
const result = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
secret: env.turnstileSecret,
response: turnstileResponse,
remoteip: ip,
}),
})
.then(r => r.json())
.catch(() => {});
return !!result?.success;
}

View File

@@ -0,0 +1,48 @@
const _stores = new Set();
export class Store {
id;
constructor(name) {
name = name.toUpperCase();
if (_stores.has(name))
throw `${name} store already exists`;
_stores.add(name);
this.id = name;
}
async _has(_key) { await Promise.reject("needs implementation"); }
has(key) {
if (typeof key !== 'string') {
key = key.toString();
}
return this._has(key);
}
async _get(_key) { await Promise.reject("needs implementation"); }
async get(key) {
if (typeof key !== 'string') {
key = key.toString();
}
const val = await this._get(key);
if (val === null)
return null;
return val;
}
async _set(_key, _val, _exp_sec = -1) { await Promise.reject("needs implementation") }
set(key, val, exp_sec = -1) {
if (typeof key !== 'string') {
key = key.toString();
}
exp_sec = Math.round(exp_sec);
return this._set(key, val, exp_sec);
}
};

View File

@@ -0,0 +1,77 @@
import { MinPriorityQueue } from '@datastructures-js/priority-queue';
import { Store } from './base-store.js';
// minimum delay between sweeps to avoid repeatedly
// sweeping entries close in proximity one by one.
const MIN_THRESHOLD_MS = 2500;
export default class MemoryStore extends Store {
#store = new Map();
#timeouts = new MinPriorityQueue/*<{ t: number, k: unknown }>*/((obj) => obj.t);
#nextSweep = { id: null, t: null };
constructor(name) {
super(name);
}
_has(key) {
return this.#store.has(key);
}
_get(key) {
const val = this.#store.get(key);
return val === undefined ? null : val;
}
_set(key, val, exp_sec = -1) {
if (this.#store.has(key)) {
this.#timeouts.remove(o => o.k === key);
}
if (exp_sec > 0) {
const exp = 1000 * exp_sec;
const timeout_at = +new Date() + exp;
this.#timeouts.enqueue({ k: key, t: timeout_at });
}
this.#store.set(key, val);
this.#reschedule();
}
#reschedule() {
const current_time = new Date().getTime();
const time = this.#timeouts.front()?.t;
if (!time) {
return;
} else if (time < current_time) {
return this.#sweepNow();
}
const sweep = this.#nextSweep;
if (sweep.id === null || sweep.t > time) {
if (sweep.id) {
clearTimeout(sweep.id);
}
sweep.t = time;
sweep.id = setTimeout(
() => this.#sweepNow(),
Math.max(MIN_THRESHOLD_MS, time - current_time)
);
sweep.id.unref();
}
}
#sweepNow() {
while (this.#timeouts.front()?.t < new Date().getTime()) {
const item = this.#timeouts.dequeue();
this.#store.delete(item.k);
}
this.#nextSweep.id = null;
this.#nextSweep.t = null;
this.#reschedule();
}
}

View File

@@ -0,0 +1,19 @@
import { env } from "../config.js";
let client, redis, redisLimiter;
export const createStore = async (name) => {
if (!env.redisURL) return;
if (!client) {
redis = await import('redis');
redisLimiter = await import('rate-limit-redis');
client = redis.createClient({ url: env.redisURL });
await client.connect();
}
return new redisLimiter.default({
prefix: `RL${name}_`,
sendCommand: (...args) => client.sendCommand(args),
});
}

View File

@@ -0,0 +1,64 @@
import { commandOptions, createClient } from "redis";
import { env } from "../config.js";
import { Store } from "./base-store.js";
export default class RedisStore extends Store {
#client = createClient({
url: env.redisURL,
});
#connected;
constructor(name) {
super(name);
this.#connected = this.#client.connect();
}
#keyOf(key) {
return this.id + '_' + key;
}
async _has(key) {
await this.#connected;
return this.#client.hExists(key);
}
async _get(key) {
await this.#connected;
const valueType = await this.#client.get(this.#keyOf(key) + '_t');
const value = await this.#client.get(
commandOptions({ returnBuffers: true }),
this.#keyOf(key)
);
if (!value) {
return null;
}
if (valueType === 'b')
return value;
else
return JSON.parse(value);
}
async _set(key, val, exp_sec = -1) {
await this.#connected;
const options = exp_sec > 0 ? { EX: exp_sec } : undefined;
if (val instanceof Buffer) {
await this.#client.set(
this.#keyOf(key) + '_t',
'b',
options
);
}
await this.#client.set(
this.#keyOf(key),
val,
options
);
}
}

10
api/src/store/store.js Normal file
View File

@@ -0,0 +1,10 @@
import { env } from '../config.js';
let _export;
if (env.redisURL) {
_export = await import('./redis-store.js');
} else {
_export = await import('./memory-store.js');
}
export default _export.default;

215
api/src/stream/ffmpeg.js Normal file
View File

@@ -0,0 +1,215 @@
import ffmpeg from "ffmpeg-static";
import { spawn } from "child_process";
import { create as contentDisposition } from "content-disposition-header";
import { env } from "../config.js";
import { destroyInternalStream } from "./manage.js";
import { hlsExceptions } from "../processing/service-config.js";
import { closeResponse, pipe, estimateTunnelLength, estimateAudioMultiplier } from "./shared.js";
const metadataTags = new Set([
"album",
"composer",
"genre",
"copyright",
"title",
"artist",
"album_artist",
"track",
"date",
"sublanguage"
]);
const convertMetadataToFFmpeg = (metadata) => {
const args = [];
for (const [ name, value ] of Object.entries(metadata)) {
if (metadataTags.has(name)) {
if (name === "sublanguage") {
args.push('-metadata:s:s:0', `language=${value}`);
continue;
}
args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, '')}`); // skipcq: JS-0004
} else {
throw `${name} metadata tag is not supported.`;
}
}
return args;
}
const killProcess = (p) => {
p?.kill('SIGTERM'); // ask the process to terminate itself gracefully
setTimeout(() => {
if (p?.exitCode === null)
p?.kill('SIGKILL'); // brutally murder the process if it didn't quit
}, 5000);
}
const getCommand = (args) => {
if (typeof env.processingPriority === 'number' && !isNaN(env.processingPriority)) {
return ['nice', ['-n', env.processingPriority.toString(), ffmpeg, ...args]]
}
return [ffmpeg, args]
}
const render = async (res, streamInfo, ffargs, estimateMultiplier) => {
let process;
const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls];
const shutdown = () => (
killProcess(process),
closeResponse(res),
urls.map(destroyInternalStream)
);
try {
const args = [
'-loglevel', '-8',
...ffargs,
];
process = spawn(...getCommand(args), {
windowsHide: true,
stdio: [
'inherit', 'inherit', 'inherit',
'pipe'
],
});
const [,,, muxOutput] = process.stdio;
res.setHeader('Connection', 'keep-alive');
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
res.setHeader(
'Estimated-Content-Length',
await estimateTunnelLength(streamInfo, estimateMultiplier)
);
pipe(muxOutput, res, shutdown);
process.on('close', shutdown);
res.on('finish', shutdown);
} catch {
shutdown();
}
}
const remux = async (streamInfo, res) => {
const format = streamInfo.filename.split('.').pop();
const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls];
const args = urls.flatMap(url => ['-i', url]);
// if the stream type is merge, we expect two URLs
if (streamInfo.type === 'merge' && urls.length !== 2) {
return closeResponse(res);
}
if (streamInfo.subtitles) {
args.push(
'-i', streamInfo.subtitles,
'-map', `${urls.length}:s`,
'-c:s', format === 'mp4' ? 'mov_text' : 'webvtt',
);
}
if (urls.length === 2) {
args.push(
'-map', '0:v',
'-map', '1:a',
);
} else {
args.push(
'-map', '0:v:0',
'-map', '0:a:0'
);
}
args.push(
'-c:v', 'copy',
...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy'])
);
if (format === 'mp4') {
args.push('-movflags', 'faststart+frag_keyframe+empty_moov');
}
if (streamInfo.type !== 'mute' && streamInfo.isHLS && hlsExceptions.has(streamInfo.service)) {
if (streamInfo.service === 'youtube' && format === 'webm') {
args.push('-c:a', 'libopus');
} else {
args.push('-c:a', 'aac', '-bsf:a', 'aac_adtstoasc');
}
}
if (streamInfo.metadata) {
args.push(...convertMetadataToFFmpeg(streamInfo.metadata));
}
args.push('-f', format === 'mkv' ? 'matroska' : format, 'pipe:3');
await render(res, streamInfo, args);
}
const convertAudio = async (streamInfo, res) => {
const args = [
'-i', streamInfo.urls,
'-vn',
...(streamInfo.audioCopy ? ['-c:a', 'copy'] : ['-b:a', `${streamInfo.audioBitrate}k`]),
];
if (streamInfo.audioFormat === 'mp3' && streamInfo.audioBitrate === '8') {
args.push('-ar', '12000');
}
if (streamInfo.audioFormat === 'opus') {
args.push('-vbr', 'off');
}
if (streamInfo.audioFormat === 'mp4a') {
args.push('-movflags', 'frag_keyframe+empty_moov');
}
if (streamInfo.metadata) {
args.push(...convertMetadataToFFmpeg(streamInfo.metadata));
}
args.push(
'-f',
streamInfo.audioFormat === 'm4a' ? 'ipod' : streamInfo.audioFormat,
'pipe:3',
);
await render(
res,
streamInfo,
args,
estimateAudioMultiplier(streamInfo) * 1.1,
);
}
const convertGif = async (streamInfo, res) => {
const args = [
'-i', streamInfo.urls,
'-vf',
'scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse',
'-loop', '0',
'-f', 'gif', 'pipe:3',
];
await render(
res,
streamInfo,
args,
60,
);
}
export default {
remux,
convertAudio,
convertGif,
}

View File

@@ -0,0 +1,141 @@
import HLS from "hls-parser";
import { createInternalStream } from "./manage.js";
import { request } from "undici";
function getURL(url) {
try {
return new URL(url);
} catch {
return null;
}
}
function transformObject(streamInfo, hlsObject) {
if (hlsObject === undefined) {
return (object) => transformObject(streamInfo, object);
}
let fullUrl;
if (getURL(hlsObject.uri)) {
fullUrl = new URL(hlsObject.uri);
} else {
fullUrl = new URL(hlsObject.uri, streamInfo.url);
}
if (fullUrl.hostname !== '127.0.0.1') {
hlsObject.uri = createInternalStream(fullUrl.toString(), streamInfo);
if (hlsObject.map) {
hlsObject.map = transformObject(streamInfo, hlsObject.map);
}
}
return hlsObject;
}
function transformMasterPlaylist(streamInfo, hlsPlaylist) {
const makeInternalStream = transformObject(streamInfo);
const makeInternalVariants = (variant) => {
variant = transformObject(streamInfo, variant);
variant.video = variant.video.map(makeInternalStream);
variant.audio = variant.audio.map(makeInternalStream);
return variant;
};
hlsPlaylist.variants = hlsPlaylist.variants.map(makeInternalVariants);
return hlsPlaylist;
}
function transformMediaPlaylist(streamInfo, hlsPlaylist) {
const makeInternalSegments = transformObject(streamInfo);
hlsPlaylist.segments = hlsPlaylist.segments.map(makeInternalSegments);
hlsPlaylist.prefetchSegments = hlsPlaylist.prefetchSegments.map(makeInternalSegments);
return hlsPlaylist;
}
const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"];
export function isHlsResponse(req, streamInfo) {
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) {
let hlsPlaylist = await req.body.text();
hlsPlaylist = HLS.parse(hlsPlaylist);
hlsPlaylist = hlsPlaylist.isMasterPlaylist
? transformMasterPlaylist(streamInfo, hlsPlaylist)
: transformMediaPlaylist(streamInfo, hlsPlaylist);
hlsPlaylist = HLS.stringify(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;
}

193
api/src/stream/internal.js Normal file
View File

@@ -0,0 +1,193 @@
import { request } from "undici";
import { Readable } from "node:stream";
import { closeRequest, getHeaders, pipe } from "./shared.js";
import { handleHlsPlaylist, isHlsResponse, probeInternalHLSTunnel } from "./internal-hls.js";
const CHUNK_SIZE = BigInt(8e6); // 8 MB
const min = (a, b) => a < b ? a : b;
const serviceNeedsChunks = new Set(["youtube", "vk"]);
async function* readChunks(streamInfo, size) {
let read = 0n, chunksSinceTransplant = 0;
while (read < size) {
if (streamInfo.controller.signal.aborted) {
throw new Error("controller aborted");
}
const chunk = await request(streamInfo.url, {
headers: {
...getHeaders(streamInfo.service),
Range: `bytes=${read}-${read + CHUNK_SIZE}`
},
dispatcher: streamInfo.dispatcher,
signal: streamInfo.controller.signal,
maxRedirections: 4
});
if (chunk.statusCode === 403 && chunksSinceTransplant >= 3 && streamInfo.transplant) {
chunksSinceTransplant = 0;
try {
await streamInfo.transplant(streamInfo.dispatcher);
continue;
} catch {}
}
chunksSinceTransplant++;
const expected = min(CHUNK_SIZE, size - read);
const received = BigInt(chunk.headers['content-length']);
if (received < expected / 2n) {
closeRequest(streamInfo.controller);
}
for await (const data of chunk.body) {
yield data;
}
read += received;
}
}
async function handleChunkedStream(streamInfo, res) {
const { signal } = streamInfo.controller;
const cleanup = () => (res.end(), closeRequest(streamInfo.controller));
try {
let req, attempts = 3;
while (attempts--) {
req = await fetch(streamInfo.url, {
headers: getHeaders(streamInfo.service),
method: 'HEAD',
dispatcher: streamInfo.dispatcher,
signal
});
streamInfo.url = req.url;
if (req.status === 403 && streamInfo.transplant) {
try {
await streamInfo.transplant(streamInfo.dispatcher);
} catch {
break;
}
} else break;
}
const size = BigInt(req.headers.get('content-length'));
if (req.status !== 200 || !size) {
return cleanup();
}
const generator = readChunks(streamInfo, size);
const abortGenerator = () => {
generator.return();
signal.removeEventListener('abort', abortGenerator);
}
signal.addEventListener('abort', abortGenerator);
const stream = Readable.from(generator);
for (const headerName of ['content-type', 'content-length']) {
const headerValue = req.headers.get(headerName);
if (headerValue) res.setHeader(headerName, headerValue);
}
pipe(stream, res, cleanup);
} catch {
cleanup();
}
}
async function handleGenericStream(streamInfo, res) {
const { signal } = streamInfo.controller;
const cleanup = () => res.end();
try {
const fileResponse = await request(streamInfo.url, {
headers: {
...Object.fromEntries(streamInfo.headers),
host: undefined
},
dispatcher: streamInfo.dispatcher,
signal,
maxRedirections: 16
});
res.status(fileResponse.statusCode);
fileResponse.body.on('error', () => {});
const isHls = isHlsResponse(fileResponse, streamInfo);
for (const [ name, value ] of Object.entries(fileResponse.headers)) {
if (!isHls || name.toLowerCase() !== 'content-length') {
res.setHeader(name, value);
}
}
if (fileResponse.statusCode < 200 || fileResponse.statusCode > 299) {
return cleanup();
}
if (isHls) {
await handleHlsPlaylist(streamInfo, fileResponse, res);
} else {
pipe(fileResponse.body, res, cleanup);
}
} catch {
closeRequest(streamInfo.controller);
cleanup();
}
}
export function internalStream(streamInfo, res) {
if (streamInfo.headers) {
streamInfo.headers.delete('icy-metadata');
}
if (serviceNeedsChunks.has(streamInfo.service) && !streamInfo.isHLS) {
return handleChunkedStream(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 {}
}

307
api/src/stream/manage.js Normal file
View File

@@ -0,0 +1,307 @@
import Store from "../store/store.js";
import { nanoid } from "nanoid";
import { randomBytes } from "crypto";
import { strict as assert } from "assert";
import { setMaxListeners } from "node:events";
import { env } from "../config.js";
import { closeRequest } from "./shared.js";
import { decryptStream, encryptStream } from "../misc/crypto.js";
import { hashHmac } from "../security/secrets.js";
import { zip } from "../misc/utils.js";
// optional dependency
const freebind = env.freebindCIDR && await import('freebind').catch(() => {});
const streamCache = new Store('streams');
const internalStreamCache = new Map();
export function createStream(obj) {
const streamID = nanoid(),
iv = randomBytes(16).toString('base64url'),
secret = randomBytes(32).toString('base64url'),
exp = new Date().getTime() + env.streamLifespan * 1000,
hmac = hashHmac(`${streamID},${exp},${iv},${secret}`, 'stream').toString('base64url'),
streamData = {
exp: exp,
type: obj.type,
urls: obj.url,
service: obj.service,
filename: obj.filename,
requestIP: obj.requestIP,
headers: obj.headers,
metadata: obj.fileMetadata || false,
audioBitrate: obj.audioBitrate,
audioCopy: !!obj.audioCopy,
audioFormat: obj.audioFormat,
isHLS: obj.isHLS || false,
originalRequest: obj.originalRequest,
// url to a subtitle file
subtitles: obj.subtitles,
};
// FIXME: this is now a Promise, but it is not awaited
// here. it may happen that the stream is not
// stored in the Store before it is requested.
streamCache.set(
streamID,
encryptStream(streamData, iv, secret),
env.streamLifespan
);
let streamLink = new URL('/tunnel', env.apiURL);
const params = {
'id': streamID,
'exp': exp,
'sig': hmac,
'sec': secret,
'iv': iv
}
for (const [key, value] of Object.entries(params)) {
streamLink.searchParams.append(key, value);
}
return streamLink.toString();
}
export function createProxyTunnels(info) {
const proxyTunnels = [];
let urls = info.url;
if (typeof urls === "string") {
urls = [urls];
}
const tunnelTemplate = {
type: "proxy",
headers: info?.headers,
requestIP: info?.requestIP,
}
for (const url of urls) {
proxyTunnels.push(
createStream({
...tunnelTemplate,
url,
service: info?.service,
originalRequest: info?.originalRequest,
})
);
}
if (info.subtitles) {
proxyTunnels.push(
createStream({
...tunnelTemplate,
url: info.subtitles,
service: `${info?.service}-subtitles`,
})
);
}
if (info.cover) {
proxyTunnels.push(
createStream({
...tunnelTemplate,
url: info.cover,
service: `${info?.service}-cover`,
})
);
}
return proxyTunnels;
}
export function getInternalTunnel(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 = {}, isSubtitles) {
assert(typeof url === 'string');
let dispatcher = obj.dispatcher;
if (obj.requestIP) {
dispatcher = freebind?.dispatcherFromIP(obj.requestIP, { strict: false })
}
const streamID = nanoid();
let controller = obj.controller;
if (!controller) {
controller = new AbortController();
setMaxListeners(Infinity, controller.signal);
}
let headers;
if (obj.headers) {
headers = new Map(Object.entries(obj.headers));
}
// subtitles don't need special treatment unlike big media files
const service = isSubtitles ? `${obj.service}-subtitles` : obj.service;
internalStreamCache.set(streamID, {
url,
service,
headers,
controller,
dispatcher,
isHLS: obj.isHLS,
transplant: obj.transplant
});
let streamLink = new URL('/itunnel', `http://127.0.0.1:${env.tunnelPort}`);
streamLink.searchParams.set('id', streamID);
const cleanup = () => {
destroyInternalStream(streamLink);
controller.signal.removeEventListener('abort', cleanup);
}
controller.signal.addEventListener('abort', cleanup);
return streamLink.toString();
}
function getInternalTunnelId(url) {
url = new URL(url);
if (url.hostname !== '127.0.0.1') {
return;
}
return url.searchParams.get('id');
}
export function destroyInternalStream(url) {
const id = getInternalTunnelId(url);
if (internalStreamCache.has(id)) {
closeRequest(getInternalTunnel(id)?.controller);
internalStreamCache.delete(id);
}
}
const transplantInternalTunnels = function(tunnelUrls, transplantUrls) {
if (tunnelUrls.length !== transplantUrls.length) {
return;
}
for (const [ tun, url ] of zip(tunnelUrls, transplantUrls)) {
const id = getInternalTunnelId(tun);
const itunnel = getInternalTunnel(id);
if (!itunnel) continue;
itunnel.url = url;
}
}
const transplantTunnel = async function (dispatcher) {
if (this.pendingTransplant) {
await this.pendingTransplant;
return;
}
let finished;
this.pendingTransplant = new Promise(r => finished = r);
try {
const handler = await import(`../processing/services/${this.service}.js`);
const response = await handler.default({
...this.originalRequest,
dispatcher
});
if (!response.urls) {
return;
}
response.urls = [response.urls].flat();
if (this.originalRequest.isAudioOnly && response.urls.length > 1) {
response.urls = [response.urls[1]];
} else if (this.originalRequest.isAudioMuted) {
response.urls = [response.urls[0]];
}
const tunnels = [this.urls].flat();
if (tunnels.length !== response.urls.length) {
return;
}
transplantInternalTunnels(tunnels, response.urls);
}
catch {}
finally {
finished();
delete this.pendingTransplant;
}
}
function wrapStream(streamInfo) {
const url = streamInfo.urls;
if (streamInfo.originalRequest) {
streamInfo.transplant = transplantTunnel.bind(streamInfo);
}
if (typeof url === 'string') {
streamInfo.urls = createInternalStream(url, streamInfo);
} else if (Array.isArray(url)) {
for (const idx in streamInfo.urls) {
streamInfo.urls[idx] = createInternalStream(
streamInfo.urls[idx], streamInfo
);
}
} else throw 'invalid urls';
if (streamInfo.subtitles) {
streamInfo.subtitles = createInternalStream(
streamInfo.subtitles,
streamInfo,
/*isSubtitles=*/true
);
}
return streamInfo;
}
export async function verifyStream(id, hmac, exp, secret, iv) {
try {
const ghmac = hashHmac(`${id},${exp},${iv},${secret}`, 'stream').toString('base64url');
const cache = await streamCache.get(id.toString());
if (ghmac !== String(hmac)) return { status: 401 };
if (!cache) return { status: 404 };
const streamInfo = JSON.parse(decryptStream(cache, iv, secret));
if (!streamInfo) return { status: 404 };
if (Number(exp) <= new Date().getTime())
return { status: 404 };
return wrapStream(streamInfo);
}
catch {
return { status: 500 };
}
}

43
api/src/stream/proxy.js Normal file
View File

@@ -0,0 +1,43 @@
import { Agent, request } from "undici";
import { create as contentDisposition } from "content-disposition-header";
import { destroyInternalStream } from "./manage.js";
import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js";
const defaultAgent = new Agent();
export default async function (streamInfo, res) {
const abortController = new AbortController();
const shutdown = () => (
closeRequest(abortController),
closeResponse(res),
destroyInternalStream(streamInfo.urls)
);
try {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Content-disposition', contentDisposition(streamInfo.filename));
const { body: stream, headers, statusCode } = await request(streamInfo.urls, {
headers: {
...getHeaders(streamInfo.service),
Range: streamInfo.range
},
signal: abortController.signal,
maxRedirections: 16,
dispatcher: defaultAgent,
});
res.status(statusCode);
for (const headerName of ['accept-ranges', 'content-type', 'content-length']) {
if (headers[headerName]) {
res.setHeader(headerName, headers[headerName]);
}
}
pipe(stream, res, shutdown);
} catch {
shutdown();
}
}

91
api/src/stream/shared.js Normal file
View File

@@ -0,0 +1,91 @@
import { genericUserAgent } from "../config.js";
import { vkClientAgent } from "../processing/services/vk.js";
import { getInternalTunnelFromURL } from "./manage.js";
import { probeInternalTunnel } from "./internal.js";
const defaultHeaders = {
'user-agent': genericUserAgent
}
const serviceHeaders = {
bilibili: {
referer: 'https://www.bilibili.com/'
},
youtube: {
accept: '*/*',
origin: 'https://www.youtube.com',
referer: 'https://www.youtube.com',
DNT: '?1'
},
vk: {
'user-agent': vkClientAgent
},
tiktok: {
referer: 'https://www.tiktok.com/',
}
}
export function closeRequest(controller) {
try { controller.abort() } catch {}
}
export function closeResponse(res) {
if (!res.headersSent) {
res.sendStatus(500);
}
return res.end();
}
export function getHeaders(service) {
// Converting all header values to strings
return Object.entries({ ...defaultHeaders, ...serviceHeaders[service] })
.reduce((p, [key, val]) => ({ ...p, [key]: String(val) }), {})
}
export function pipe(from, to, done) {
from.on('error', done)
.on('close', done);
to.on('error', done)
.on('close', done);
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;
}

32
api/src/stream/stream.js Normal file
View File

@@ -0,0 +1,32 @@
import proxy from "./proxy.js";
import ffmpeg from "./ffmpeg.js";
import { closeResponse } from "./shared.js";
import { internalStream } from "./internal.js";
export default async function(res, streamInfo) {
try {
switch (streamInfo.type) {
case "proxy":
return await proxy(streamInfo, res);
case "internal":
return await internalStream(streamInfo.data, res);
case "merge":
case "remux":
case "mute":
return await ffmpeg.remux(streamInfo, res);
case "audio":
return await ffmpeg.convertAudio(streamInfo, res);
case "gif":
return await ffmpeg.convertGif(streamInfo, res);
}
closeResponse(res);
} catch {
closeResponse(res);
}
}

View File

@@ -0,0 +1,22 @@
// run with `pnpm -r token:jwt`
const makeSecureString = (length = 64) => {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
const out = [];
while (out.length < length) {
for (const byte of crypto.getRandomValues(new Uint8Array(length))) {
if (byte < alphabet.length) {
out.push(alphabet[byte]);
}
if (out.length === length) {
break;
}
}
}
return out.join('');
}
console.log(`JWT_SECRET: ${JSON.stringify(makeSecureString(64))}`)

136
api/src/util/test.js Normal file
View File

@@ -0,0 +1,136 @@
import path from "node:path";
import { env } from "../config.js";
import { runTest } from "../misc/run-test.js";
import { loadJSON } from "../misc/load-from-fs.js";
import { Red, Bright } from "../misc/console-text.js";
import { setGlobalDispatcher, EnvHttpProxyAgent, ProxyAgent } from "undici";
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
import { services } from "../processing/service-config.js";
const getTestPath = service => path.join('./src/util/tests/', `./${service}.json`);
const getTests = (service) => loadJSON(getTestPath(service));
// services that are known to frequently fail due to external
// factors (e.g. rate limiting)
const finnicky = new Set(
process.env.TEST_IGNORE_SERVICES
? process.env.TEST_IGNORE_SERVICES.split(',')
: ['bilibili', 'instagram', 'facebook', 'youtube', 'vk', 'twitter', 'reddit']
);
const runTestsFor = async (service) => {
const tests = getTests(service);
let softFails = 0, fails = 0;
if (!tests) {
throw "no such service: " + service;
}
for (const test of tests) {
const { name, url, params, expected } = test;
const canFail = test.canFail || finnicky.has(service);
try {
await runTest(url, params, expected);
console.log(`${service}/${name}: ok`);
} catch (e) {
softFails += !canFail;
fails++;
let failText = canFail ? `${Red('FAIL')} (ignored)` : Bright(Red('FAIL'));
if (canFail && process.env.GITHUB_ACTION) {
console.log(`::warning title=${service}/${name.replace(/,/g, ';')}::failed and was ignored`);
}
console.error(`${service}/${name}: ${failText}`);
const errorString = e.toString().split('\n');
let c = '┃';
errorString.forEach((line, index) => {
line = line.replace('!=', Red('!='));
if (index === errorString.length - 1) {
c = '┗';
}
console.error(` ${c}`, line);
});
}
}
return { fails, softFails };
}
const printHeader = (service, padLen) => {
const padding = padLen - service.length;
service = service.padEnd(1 + service.length + padding, ' ');
console.log(service + '='.repeat(50));
}
// TODO: remove env.externalProxy in a future version
setGlobalDispatcher(
new EnvHttpProxyAgent({ httpProxy: env.externalProxy || undefined })
);
env.streamLifespan = 10000;
env.apiURL = 'http://x/';
randomizeCiphers();
const action = process.argv[2];
switch (action) {
case "get-services":
const fromConfig = Object.keys(services);
const missingTests = fromConfig.filter(
service => {
const tests = getTests(service);
return !tests || tests.length === 0
}
);
if (missingTests.length) {
console.error('services have no tests:', missingTests);
process.exitCode = 1;
break;
}
console.log(JSON.stringify(fromConfig));
break;
case "run-tests-for":
try {
const { softFails } = await runTestsFor(process.argv[3]);
process.exitCode = Number(!!softFails);
} catch (e) {
console.error(e);
process.exitCode = 1;
break;
}
break;
default:
const maxHeaderLen = Object.keys(services).reduce((n, v) => v.length > n ? v.length : n, 0);
const failCounters = {};
for (const service in services) {
printHeader(service, maxHeaderLen);
const { fails, softFails } = await runTestsFor(service);
failCounters[service] = fails;
console.log();
if (!process.exitCode && softFails)
process.exitCode = 1;
}
console.log('='.repeat(50 + maxHeaderLen));
console.log(
Bright('total fails:'),
Object.values(failCounters).reduce((a, b) => a + b)
);
for (const [ service, fails ] of Object.entries(failCounters)) {
if (fails) console.log(`${Bright(service)} fails: ${fails}`);
}
}

View File

@@ -0,0 +1,69 @@
[
{
"name": "1080p video",
"url": "https://www.bilibili.com/video/BV18i4y1m7xV/",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "1080p video muted",
"url": "https://www.bilibili.com/video/BV18i4y1m7xV/",
"params": {
"downloadMode": "mute"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "1080p vertical video",
"url": "https://www.bilibili.com/video/BV1uu411z7VV/",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "1080p vertical video muted",
"url": "https://www.bilibili.com/video/BV1uu411z7VV/",
"params": {
"downloadMode": "mute"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "b23.tv shortlink",
"url": "https://b23.tv/av32430100",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "bilibili.tv link",
"url": "https://www.bilibili.tv/en/video/4789599404426256",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "bilibili.com link with part id",
"url": "https://www.bilibili.com/video/BV1uo4y1K72s?spm_id_from=333.788.videopod.episodes&p=6",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
}
]

View File

@@ -0,0 +1,96 @@
[
{
"name": "horizontal video",
"url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3giwtwp222m",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "horizontal video, recordWithMedia",
"url": "https://bsky.app/profile/did:plc:ywbm3iywnhzep3ckt6efhoh7/post/3l3wonhk23g2i",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "vertical video",
"url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3jhpomhjk2m",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "vertical video (muted)",
"url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3jhpomhjk2m",
"params": {
"downloadMode": "mute"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "vertical video (audio)",
"url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3jhpomhjk2m",
"params": {
"downloadMode": "audio"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "single image",
"url": "https://bsky.app/profile/did:plc:k4a7d65fcyevbrnntjxh57go/post/3l33flpoygt26",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "gif with a quoted post",
"url": "https://bsky.app/profile/imlunahey.com/post/3lgajpn5dtk2t",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "gif alone in a post",
"url": "https://bsky.app/profile/imlunahey.com/post/3lgah3ovxnc2q",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "several images",
"url": "https://bsky.app/profile/did:plc:rai7s6su2sy22ss7skouedl7/post/3kzxuxbiul626",
"params": {},
"expected": {
"code": 200,
"status": "picker"
}
},
{
"name": "deleted post/invalid user",
"url": "https://bsky.app/profile/notreal.bsky.team/post/3l2udah76ch2c",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}
]

View File

@@ -0,0 +1,29 @@
[
{
"name": "regular video",
"url": "https://www.dailymotion.com/video/x8t1eho",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "private video",
"url": "https://www.dailymotion.com/video/k41fZWpx2TaAORA2nok",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "dai.ly shortened link",
"url": "https://dai.ly/k41fZWpx2TaAORA2nok",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
}
]

View File

@@ -0,0 +1,65 @@
[
{
"name": "direct video with username and id",
"url": "https://web.facebook.com/100071784061914/videos/588631943886661/",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "direct video with id as query param",
"url": "https://web.facebook.com/watch/?v=883839773514682&ref=sharing",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "direct video with caption",
"url": "https://web.facebook.com/wood57/videos/𝐒𝐞𝐛𝐚𝐬𝐤𝐨𝐦-𝐟𝐮𝐥𝐥/883839773514682",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "shortlink video",
"url": "https://fb.watch/r1K6XHMfGT/",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "reel video",
"url": "https://web.facebook.com/reel/730293269054758",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "shared video link",
"url": "https://www.facebook.com/share/v/6EJK4Z8EAEAHtz8K/",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "shared video link v2",
"url": "https://web.facebook.com/share/r/JFZfPVgLkiJQmWrr/",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
}
]

View File

@@ -0,0 +1,134 @@
[
{
"name": "single photo post",
"url": "https://www.instagram.com/p/DFx6KVduFWy/",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "various picker (photos + video)",
"url": "https://www.instagram.com/p/CvYrSgnsKjv/",
"params": {},
"expected": {
"code": 200,
"status": "picker"
}
},
{
"name": "reel",
"url": "https://www.instagram.com/reel/DFQe23tOWKz/",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "regular video",
"url": "https://www.instagram.com/p/CmCVWoIr9OH/",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "reel (isAudioOnly)",
"url": "https://www.instagram.com/reel/DFQe23tOWKz/",
"params": {
"downloadMode": "audio"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "reel (isAudioMuted)",
"url": "https://www.instagram.com/reel/DFQe23tOWKz/",
"params": {
"downloadMode": "mute"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "inexistent reel",
"url": "https://www.instagram.com/reel/XXXXXXXXXX/",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
},
{
"name": "inexistent post",
"url": "https://www.instagram.com/p/XXXXXXXXXX/",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
},
{
"name": "post info in an array (for whatever reason??)",
"url": "https://www.instagram.com/reel/CrVB9tatUDv/?igshid=blaBlABALALbLABULLSHIT==",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "prone to get rate limited",
"url": "https://www.instagram.com/reel/CrO-T7Qo6rq/?igshid=fuckYouNoTrackingIdForYou==",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "ddinstagram link",
"url": "https://ddinstagram.com/p/CmCVWoIr9OH/",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "d.ddinstagram.com link",
"url": "https://d.ddinstagram.com/p/CmCVWoIr9OH/",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "g.ddinstagram.com link",
"url": "https://g.ddinstagram.com/p/CmCVWoIr9OH/",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "private instagram post",
"url": "https://www.instagram.com/p/C5_A1TQNPrYw4c2g9KAUTPUl8RVHqiAdAcOOSY0",
"canFail": true,
"params": {},
"expected": {
"code": 400,
"status": "error",
"errorCode": "error.api.content.post.private"
}
}
]

View File

@@ -0,0 +1,60 @@
[
{
"name": "1080p video",
"url": "https://www.loom.com/share/d165fd054a294d8a8587807bcc50c885",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "1080p video (muted)",
"url": "https://www.loom.com/share/d165fd054a294d8a8587807bcc50c885",
"params": {
"downloadMode": "mute"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "1080p video (audio only)",
"url": "https://www.loom.com/share/d165fd054a294d8a8587807bcc50c885",
"params": {
"downloadMode": "audio"
},
"expected": {
"code": 400,
"status": "error"
}
},
{
"name": "video with no transcodedUrl",
"url": "https://www.loom.com/share/aa3d8b08bee74d05af5b42989e9f33e9",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "video with title in url",
"url": "https://www.loom.com/share/Meet-AI-workflows-aa3d8b08bee74d05af5b42989e9f33e9",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "video with title in url (2)",
"url": "https://www.loom.com/share/Unlocking-Incredible-Organizational-Velocity-with-Async-Video-4a2a8baf124c4390954dcbb46a58cfd7",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
}
]

View File

@@ -0,0 +1,42 @@
[
{
"name": "regular video",
"url": "https://www.newgrounds.com/portal/view/938050",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "regular video (audio only)",
"url": "https://www.newgrounds.com/portal/view/938050",
"params": {
"downloadMode": "audio"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "regular video (muted)",
"url": "https://www.newgrounds.com/portal/view/938050",
"params": {
"downloadMode": "mute"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "regular music",
"url": "https://www.newgrounds.com/audio/listen/500476",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
}
]

View File

@@ -0,0 +1,11 @@
[
{
"name": "regular video",
"url": "https://ok.ru/video/7204071410346",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
}
]

View File

@@ -0,0 +1,97 @@
[
{
"name": "regular video",
"url": "https://www.pinterest.com/pin/70437485604616/",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "invalid link",
"url": "https://www.pinterest.com/pin/eeeeeee/",
"params": {},
"expected": {
"code": 400,
"status": "error",
"errorCode": "error.api.fetch.empty"
}
},
{
"name": "regular video (isAudioOnly)",
"url": "https://www.pinterest.com/pin/70437485604616/",
"params": {
"downloadMode": "audio"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "regular video (isAudioMuted)",
"url": "https://www.pinterest.com/pin/70437485604616/",
"params": {
"downloadMode": "mute"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "regular video (.ca TLD)",
"url": "https://www.pinterest.ca/pin/70437485604616/",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "story",
"url": "https://www.pinterest.com/pin/gadget-cool-products-amazon-product-technology-kitchen-gadgets--1084663891475263837/",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "regular picture",
"url": "https://www.pinterest.com/pin/412994228343400946/",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "regular picture (.ca TLD)",
"url": "https://www.pinterest.ca/pin/412994228343400946/",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "regular gif",
"url": "https://www.pinterest.com/pin/643170390530326178/",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "regular gif (.ca TLD)",
"url": "https://www.pinterest.ca/pin/643170390530326178/",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
}
]

View File

@@ -0,0 +1,78 @@
[
{
"name": "video with audio",
"url": "https://www.reddit.com/r/TikTokCringe/comments/wup1fg/id_be_escaping_at_the_first_chance_i_got/?utm_source=share&utm_medium=web2x&context=3",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "video with audio (isAudioOnly)",
"url": "https://www.reddit.com/r/TikTokCringe/comments/wup1fg/id_be_escaping_at_the_first_chance_i_got/?utm_source=share&utm_medium=web2x&context=3",
"params": {
"downloadMode": "audio"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "video with audio (isAudioMuted)",
"url": "https://www.reddit.com/r/TikTokCringe/comments/wup1fg/id_be_escaping_at_the_first_chance_i_got/?utm_source=share&utm_medium=web2x&context=3",
"params": {
"downloadMode": "mute"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "video without audio",
"url": "https://www.reddit.com/r/catvideos/comments/ftoeo7/luna_doesnt_want_to_be_bothered_while_shes_napping/?utm_source=share&utm_medium=web2x&context=3",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "actual gif, not looping video",
"url": "https://www.reddit.com/r/whenthe/comments/109wqy1/god_really_did_some_trolling/?utm_source=share&utm_medium=web2x&context=3",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "different audio link, live render",
"url": "https://www.reddit.com/r/TikTokCringe/comments/15hce91/asian_daddy_kink/",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "shortened video link",
"url": "https://v.redd.it/ifg2emt5ck0e1",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "shortened video link (alternative)",
"url": "https://reddit.com/video/ifg2emt5ck0e1",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
}
]

View File

@@ -0,0 +1,100 @@
[
{
"name": "regular video",
"url": "https://rutube.ru/video/b2f6c27649907c2fde0af411b03825eb/",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "vertical video (isAudioMuted)",
"url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/",
"params": {
"downloadMode": "mute"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "russian region lock",
"url": "https://rutube.ru/video/b521653b4f71ece57b8ff54e57ca9b82/",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
},
{
"name": "vertical video",
"url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "yappy",
"url": "https://rutube.ru/yappy/c8c32bf7aee04412837656ea26c2b25b/",
"canFail": true,
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "shorts",
"url": "https://rutube.ru/shorts/935c1afafd0e7d52836d671967d53dac/",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "vertical video (isAudioOnly)",
"url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/",
"params": {
"downloadMode": "audio"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "vertical video (isAudioMuted)",
"url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/",
"params": {
"downloadMode": "mute"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "private video",
"url": "https://rutube.ru/video/private/1161415be0e686214bb2a498165cab3e/?p=_IL1G8RSnKutunnTYwhZ5A",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "region locked video, should fail",
"canFail": true,
"url": "https://rutube.ru/video/e7ac82708cc22bd068a3bf6a7004d1b1/",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}
]

View File

@@ -0,0 +1,29 @@
[
{
"name": "spotlight",
"url": "https://www.snapchat.com/spotlight/W7_EDlXWTBiXAEEniNoMPwAAYdWxucG9pZmNqAY46j0a5AY46j0YbAAAAAQ",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "shortlinked spotlight",
"url": "https://t.snapchat.com/4ZsiBLDi",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "story",
"url": "https://www.snapchat.com/add/bazerkmakane",
"params": {},
"expected": {
"code": 200,
"status": "picker"
}
}
]

View File

@@ -0,0 +1,107 @@
[
{
"name": "public song (best)",
"url": "https://soundcloud.com/l2share77/loona-butterfly?utm_source=clipboard&utm_medium=text&utm_campaign=social_sharing",
"params": {
"audioFormat": "best"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "public song (mp3, isAudioMuted)",
"url": "https://soundcloud.com/l2share77/loona-butterfly?utm_source=clipboard&utm_medium=text&utm_campaign=social_sharing",
"params": {
"downloadMode": "mute",
"audioFormat": "mp3"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "private song",
"url": "https://soundcloud.com/user-798052861/asdasdasdsdsd/s-9TqZ7edLJ90",
"params": {
"audioFormat": "mp3"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "private song (wav, isAudioMuted)",
"url": "https://soundcloud.com/user-798052861/asdasdasdsdsd/s-9TqZ7edLJ90",
"params": {
"downloadMode": "mute",
"audioFormat": "wav"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "private song (ogg, isAudioMuted, isAudioOnly)",
"url": "https://soundcloud.com/user-798052861/asdasdasdsdsd/s-9TqZ7edLJ90",
"params": {
"downloadMode": "audio",
"audioFormat": "ogg"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "on.soundcloud link",
"url": "https://on.soundcloud.com/XHLLKSXRQ5yyGDuD9",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "on.soundcloud link, different stream type",
"url": "https://on.soundcloud.com/AG4c",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "no opus audio, fallback to mp3",
"url": "https://soundcloud.com/frums/credits",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "go+ song, should fail",
"canFail": true,
"url": "https://soundcloud.com/dualipa/physical-feat-troye-sivan",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
},
{
"name": "region locked song, should fail",
"canFail": true,
"url": "https://soundcloud.com/gotye/somebody-2024-feat-kimbra",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}
]

View File

@@ -0,0 +1,51 @@
[
{
"name": "regular video",
"url": "https://streamable.com/p9cln4",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "embedded link",
"url": "https://streamable.com/e/rsmo56",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "regular video (isAudioOnly)",
"url": "https://streamable.com/p9cln4",
"params": {
"downloadMode": "audio"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "regular video (isAudioMuted)",
"url": "https://streamable.com/p9cln4",
"params": {
"downloadMode": "mute"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "inexistent video",
"url": "https://streamable.com/XXXXXX",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}
]

View File

@@ -0,0 +1,47 @@
[
{
"name": "long link video",
"url": "https://www.tiktok.com/@fatfatmillycat/video/7195741644585454894",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "images",
"url": "https://www.tiktok.com/@matryoshk4/video/7231234675476532526",
"params": {},
"expected": {
"code": 200,
"status": "picker"
}
},
{
"name": "long link inexistent",
"url": "https://www.tiktok.com/@blablabla/video/7120851458451417478",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
},
{
"name": "short link inexistent",
"url": "https://vt.tiktok.com/2p4ewa7/",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
},
{
"name": "age restricted video",
"url": "https://www.tiktok.com/@.kyle.films/video/7415757181145877793",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}
]

View File

@@ -0,0 +1,49 @@
[
{
"name": "at.tumblr link",
"url": "https://at.tumblr.com/music/704177038274281472/n7x7pr7x4w2b",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "user subdomain link",
"url": "https://garfield-69.tumblr.com/post/696499862852780032",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "web app link",
"url": "https://www.tumblr.com/rongzhi/707729381162958848/english-added-by-me?source=share",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "tumblr audio",
"url": "https://www.tumblr.com/zedneon/737815079301562368/zedneon-ft-mr-sauceman-tech-n9ne-speed-of?source=share",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "tumblr video converted to audio",
"url": "https://garfield-69.tumblr.com/post/696499862852780032",
"params": {
"downloadMode": "audio"
},
"expected": {
"code": 200,
"status": "tunnel"
}
}
]

View File

@@ -0,0 +1,42 @@
[
{
"name": "clip",
"url": "https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "clip (isAudioOnly)",
"url": "https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G",
"params": {
"downloadMode": "audio"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "clip (isAudioMuted)",
"url": "https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G",
"params": {
"downloadMode": "mute"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "clip (mobile subdomain)",
"url": "https://m.twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
}
]

Some files were not shown because too many files have changed in this diff Show More