1293 Commits

Author SHA1 Message Date
5231090be3 Add api/meow.js
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Run service tests / test service functionality (push) Has been cancelled
Run service tests / test service: ${{ matrix.service }} (push) Has been cancelled
Run tests / check lockfile correctness (push) Has been cancelled
Run tests / web sanity check (push) Has been cancelled
Run tests / api sanity check (push) Has been cancelled
2025-12-05 20:24:17 +05:30
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
357 changed files with 17201 additions and 7177 deletions

3
.github/test.sh vendored
View File

@@ -18,7 +18,7 @@ test_api() {
-X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"url":"https://www.tiktok.com/@fatfatmillycat/video/7195741644585454894"}')
-d '{"url":"https://garfield-69.tumblr.com/post/696499862852780032","alwaysProxy":true}')
echo "API_RESPONSE=$API_RESPONSE"
STATUS=$(echo "$API_RESPONSE" | jq -r .status)
@@ -46,6 +46,7 @@ setup_api() {
}
setup_web() {
pnpm run --prefix web check
pnpm run --prefix web build
}

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:
@@ -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 }}

View File

@@ -17,7 +17,7 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- id: checkServices
run: pnpm i --frozen-lockfile && echo "service_list=$(node api/src/util/test-ci get-services)" >> "$GITHUB_OUTPUT"
run: pnpm i --frozen-lockfile && echo "service_list=$(node api/src/util/test get-services)" >> "$GITHUB_OUTPUT"
test-services:
needs: check-services
@@ -30,4 +30,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm i --frozen-lockfile && node api/src/util/test-ci run-tests-for ${{ matrix.service }}
- 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

@@ -24,6 +24,8 @@ jobs:
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

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@ build
.env.*
!.env.example
cookies.json
keys.json
# docker
docker-compose.yml

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,4 +1,4 @@
FROM node:20-bullseye-slim AS base
FROM node:24-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
@@ -7,8 +7,7 @@ WORKDIR /app
COPY . /app
RUN corepack enable
RUN apt-get update && \
apt-get install -y python3 build-essential
RUN apk add --no-cache python3 alpine-sdk
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --prod --frozen-lockfile
@@ -18,8 +17,10 @@ RUN pnpm deploy --filter=@imput/cobalt-api --prod /prod/api
FROM base AS api
WORKDIR /app
COPY --from=build /prod/api /app
COPY --from=build /app/.git /app/.git
COPY --from=build --chown=node:node /prod/api /app
COPY --from=build --chown=node:node /app/.git /app/.git
USER node
EXPOSE 9000
CMD [ "node", "src/cobalt" ]

128
README.md
View File

@@ -14,109 +14,59 @@
<a href="https://discord.gg/pQPt8HBUPu">
💬 community discord server
</a>
<br/>
<a href="https://x.com/justusecobalt">
🐦 twitter/x
🐦 twitter
</a>
<a href="https://bsky.app/profile/cobalt.tools">
🦋 bluesky
</a>
</p>
<br/>
</div>
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 paywalls***.
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.
paste the link, get the file, move on. it's that simple. just how it should be.
paste the link, get the file, move on. that simple, just how it should be.
### 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 👀).
### 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>
| service | video + audio | only audio | only video | metadata | rich file names |
| :-------- | :-----------: | :--------: | :--------: | :------: | :-------------: |
| bilibili | ✅ | ✅ | ✅ | | |
| bluesky | ✅ | ✅ | ✅ | | |
| dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ |
| instagram | ✅ | ✅ | ✅ | | |
| facebook | ✅ | ❌ | ✅ | | |
| loom | ✅ | ❌ | ✅ | ✅ | |
| ok.ru | ✅ | ❌ | ✅ | ✅ | ✅ |
| pinterest | ✅ | ✅ | ✅ | | |
| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
| rutube | ✅ | ✅ | ✅ | ✅ | ✅ |
| snapchat | ✅ | ✅ | ✅ | | |
| soundcloud | | ✅ | | ✅ | ✅ |
| streamable | ✅ | ✅ | ✅ | | |
| tiktok | ✅ | ✅ | ✅ | ❌ | ❌ |
| tumblr | ✅ | ✅ | ✅ | | |
| twitch clips | ✅ | ✅ | ✅ | ✅ | ✅ |
| twitter/x | ✅ | ✅ | ✅ | | |
| vimeo | ✅ | ✅ | ✅ | ✅ | ✅ |
| vine | ✅ | ✅ | ✅ | | |
| vk videos & clips | ✅ | ❌ | ✅ | ✅ | ✅ |
| youtube | ✅ | ✅ | ✅ | ✅ | ✅ |
### [Warp, built for coding with multiple AI agents](https://go.warp.dev/cobalt)
</div>
| emoji | meaning |
| :-----: | :---------------------- |
| ✅ | supported |
| | impossible/unreasonable |
| ❌ | not supported |
#### 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!
### 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. |
### cobalt monorepo
this monorepo includes source code for api, frontend, and related packages:
- [api tree & readme](/api/)
- [web tree & readme](/web/)
- [packages tree](/packages/)
### partners
cobalt is sponsored by [royalehosting.net](https://royalehosting.net/?partner=cobalt), all main instances are currently hosted on their network :)
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)
### 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.
### 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 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 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.
### cobalt license
### 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.
### 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).
## 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.

View File

@@ -1,4 +1,65 @@
# 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).
@@ -9,14 +70,35 @@ as long as you:
- provide a link to the license and indicate if changes to the code were made, and
- release the code under the **same license**
## running 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.
## 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.
## accessing the api
currently, there is no publicly accessible main api. we plan on providing a public api for
cobalt 10 in some form in the future. we recommend deploying your own instance if you wish
to use the latest api. you can access [the documentation](/docs/api.md) for it here.
you can [support ffmpeg here](https://ffmpeg.org/donations.html)!
if you are looking for the documentation for the old (7.x) api, you can find
it [here](https://github.com/imputnet/cobalt/blob/7/docs/api.md)
### 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.

1
api/meow.js Normal file
View File

@@ -0,0 +1 @@
""

View File

@@ -1,7 +1,7 @@
{
"name": "@imput/cobalt-api",
"description": "save what you love",
"version": "10.0.0",
"version": "11.5",
"author": "imput",
"exports": "./src/cobalt.js",
"type": "module",
@@ -10,9 +10,8 @@
},
"scripts": {
"start": "node src/cobalt",
"setup": "node src/util/setup",
"test": "node src/util/test",
"token:youtube": "node src/util/generate-youtube-tokens"
"token:jwt": "node src/util/generate-jwt-secret"
},
"repository": {
"type": "git",
@@ -24,26 +23,28 @@
},
"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",
"esbuild": "^0.14.51",
"express": "^4.18.1",
"express-rate-limit": "^6.3.0",
"express": "^4.21.2",
"express-rate-limit": "^7.4.1",
"ffmpeg-static": "^5.1.0",
"hls-parser": "^0.10.7",
"ipaddr.js": "2.1.0",
"nanoid": "^4.0.2",
"node-cache": "^5.1.2",
"psl": "1.9.0",
"ipaddr.js": "2.2.0",
"mime": "^4.0.4",
"nanoid": "^5.0.9",
"set-cookie-parser": "2.6.0",
"undici": "^5.19.1",
"undici": "^6.21.3",
"url-pattern": "1.0.3",
"youtubei.js": "^10.3.0",
"youtubei.js": "15.1.1",
"zod": "^3.23.8"
},
"optionalDependencies": {
"freebind": "^0.2.2"
"freebind": "^0.2.2",
"rate-limit-redis": "^4.2.0",
"redis": "^4.7.0"
}
}

View File

@@ -1,27 +1,37 @@
import "dotenv/config";
import express from "express";
import cluster from "node:cluster";
import path from 'path';
import { fileURLToPath } from 'url';
import path from "path";
import { fileURLToPath } from "url";
import { env } from "./config.js"
import { Bright, Green, Red } from "./misc/console-text.js";
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');
app.disable("x-powered-by");
if (env.apiURL) {
const { runAPI } = await import('./core/api.js');
runAPI(express, app, __dirname)
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(`cobalt wasn't configured yet or configuration is invalid.\n`)
+ Bright(`please run the setup script to fix this: `)
+ Green(`npm run setup`)
Red("API_URL env variable is missing, cobalt api can't start.")
)
}

View File

@@ -1,51 +1,41 @@
import { getVersion } from "@imput/version-info";
import { services } from "./processing/service-config.js";
import { loadEnvs, validateEnvs } from "./core/env.js";
const version = await getVersion();
const disabledServices = process.env.DISABLED_SERVICES?.split(',') || [];
const enabledServices = new Set(Object.keys(services).filter(e => {
if (!disabledServices.includes(e)) {
return e;
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;
}
}));
const env = {
apiURL: process.env.API_URL || '',
apiPort: process.env.API_PORT || 9000,
if (String(env[key]) !== String(newEnv[key])) {
changes.push(key);
}
env[key] = newEnv[key];
}
listenAddress: process.env.API_LISTEN_ADDRESS,
freebindCIDR: process.platform === 'linux' && process.env.FREEBIND_CIDR,
corsWildcard: process.env.CORS_WILDCARD !== '0',
corsURL: process.env.CORS_URL,
cookiePath: process.env.COOKIE_PATH,
rateLimitWindow: (process.env.RATELIMIT_WINDOW && parseInt(process.env.RATELIMIT_WINDOW)) || 60,
rateLimitMax: (process.env.RATELIMIT_MAX && parseInt(process.env.RATELIMIT_MAX)) || 20,
durationLimit: (process.env.DURATION_LIMIT && parseInt(process.env.DURATION_LIMIT)) || 10800,
streamLifespan: 90,
processingPriority: process.platform !== 'win32'
&& process.env.PROCESSING_PRIORITY
&& parseInt(process.env.PROCESSING_PRIORITY),
externalProxy: process.env.API_EXTERNAL_PROXY,
turnstileSecret: process.env.TURNSTILE_SECRET,
jwtSecret: process.env.JWT_SECRET,
jwtLifetime: process.env.JWT_EXPIRY || 120,
enabledServices,
return changes;
}
const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36";
const cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`;
await validateEnvs(env);
export {
env,
canonicalEnv,
genericUserAgent,
cobaltUserAgent,
}

View File

@@ -1,6 +1,7 @@
import cors from "cors";
import http from "node:http";
import rateLimit from "express-rate-limit";
import { setGlobalDispatcher, ProxyAgent } from "undici";
import { setGlobalDispatcher, EnvHttpProxyAgent } from "undici";
import { getCommit, getBranch, getRemote, getVersion } from "@imput/version-info";
import jwt from "../security/jwt.js";
@@ -9,14 +10,19 @@ import match from "../processing/match.js";
import { env } from "../config.js";
import { extract } from "../processing/url.js";
import { languageCode } from "../misc/utils.js";
import { Bright, Cyan } from "../misc/console-text.js";
import { generateHmac, generateSalt } from "../misc/crypto.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, getInternalStream } from "../stream/manage.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(),
@@ -28,7 +34,6 @@ const version = await getVersion();
const acceptRegex = /^application\/json(; charset=utf-8)?$/;
const ipSalt = generateSalt();
const corsConfig = env.corsWildcard ? {} : {
origin: env.corsURL,
optionsSuccessStatus: 200
@@ -39,55 +44,70 @@ const fail = (res, code, context) => {
res.status(status).json(body);
}
export const runAPI = (express, app, __dirname) => {
export const runAPI = async (express, app, __dirname, isPrimary = true) => {
const startTime = new Date();
const startTimestamp = startTime.getTime();
const serverInfo = JSON.stringify({
const getServerInfo = () => {
return JSON.stringify({
cobalt: {
version: version,
url: env.apiURL,
startTime: `${startTimestamp}`,
durationLimit: env.durationLimit,
turnstileSitekey: env.sessionEnabled ? env.turnstileSitekey : undefined,
services: [...env.enabledServices].map(e => {
return friendlyServiceName(e);
}),
},
git,
})
const apiLimiter = rateLimit({
windowMs: env.rateLimitWindow * 1000,
max: env.rateLimitMax,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: req => {
if (req.authorized) {
return generateHmac(req.header("Authorization"), ipSalt);
});
}
return generateHmac(getIP(req), ipSalt);
},
handler: (req, res) => {
const { status, body } = createResponse("error", {
const serverInfo = getServerInfo();
const handleRateExceeded = (_, res) => {
const { body } = createResponse("error", {
code: "error.api.rate_exceeded",
context: {
limit: env.rateLimitWindow
}
});
return res.status(status).json(body);
}
})
return res.status(429).json(body);
};
const apiLimiterStream = rateLimit({
windowMs: env.rateLimitWindow * 1000,
max: env.rateLimitMax,
standardHeaders: true,
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: req => generateHmac(getIP(req), ipSalt),
handler: (req, res) => {
return res.sendStatus(429)
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']);
@@ -102,11 +122,46 @@ export const runAPI = (express, app, __dirname) => {
...corsConfig,
}));
app.post('/', apiLimiter);
app.use('/tunnel', apiLimiterStream);
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.turnstileSecret || !env.jwtSecret) {
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();
}
@@ -116,34 +171,30 @@ export const runAPI = (express, app, __dirname) => {
return fail(res, "error.api.auth.jwt.missing");
}
if (!authorization.startsWith("Bearer ") || authorization.length > 256) {
if (authorization.length >= 256) {
return fail(res, "error.api.auth.jwt.invalid");
}
const verifyJwt = jwt.verify(
authorization.split("Bearer ", 2)[1]
);
if (!verifyJwt) {
const [ type, token, ...rest ] = authorization.split(" ");
if (!token || type.toLowerCase() !== 'bearer' || rest.length) {
return fail(res, "error.api.auth.jwt.invalid");
}
if (!acceptRegex.test(req.header('Accept'))) {
return fail(res, "error.api.header.accept");
if (!jwt.verify(token, getIP(req, 32))) {
return fail(res, "error.api.auth.jwt.invalid");
}
if (!acceptRegex.test(req.header('Content-Type'))) {
return fail(res, "error.api.header.content_type");
}
req.authorized = true;
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", {
@@ -155,8 +206,8 @@ export const runAPI = (express, app, __dirname) => {
next();
});
app.post("/session", async (req, res) => {
if (!env.turnstileSecret || !env.jwtSecret) {
app.post("/session", sessionLimiter, async (req, res) => {
if (!env.sessionEnabled) {
return fail(res, "error.api.auth.not_configured")
}
@@ -176,7 +227,7 @@ export const runAPI = (express, app, __dirname) => {
}
try {
res.json(jwt.generate());
res.json(jwt.generate(getIP(req, 32)));
} catch {
return fail(res, "error.api.generic");
}
@@ -184,26 +235,25 @@ export const runAPI = (express, app, __dirname) => {
app.post('/', async (req, res) => {
const request = req.body;
const lang = languageCode(req);
if (!request.url) {
return fail(res, "error.api.link.missing");
}
if (request.youtubeDubBrowserLang) {
request.youtubeDubLang = lang;
}
const { success, data: normalizedRequest } = await normalizeRequest(request);
if (!success) {
return fail(res, "error.api.invalid_body");
}
const parsed = extract(normalizedRequest.url);
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) {
@@ -217,15 +267,25 @@ export const runAPI = (express, app, __dirname) => {
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.get('/tunnel', (req, res) => {
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);
@@ -244,7 +304,7 @@ export const runAPI = (express, app, __dirname) => {
return res.status(200).end();
}
const streamInfo = verifyStream(id, sig, exp, sec, iv);
const streamInfo = await verifyStream(id, sig, exp, sec, iv);
if (!streamInfo?.service) {
return res.status(streamInfo.status).end();
}
@@ -254,33 +314,11 @@ export const runAPI = (express, app, __dirname) => {
}
return stream(res, streamInfo);
})
app.get('/itunnel', (req, res) => {
if (!req.ip.endsWith('127.0.0.1')) {
return res.sendStatus(403);
}
if (String(req.query.id).length !== 21) {
return res.sendStatus(400);
}
const streamInfo = getInternalStream(req.query.id);
if (!streamInfo) {
return res.sendStatus(404);
}
streamInfo.headers = new Map([
...(streamInfo.headers || []),
...Object.entries(req.headers)
]);
return stream(res, { type: 'internal', ...streamInfo });
})
});
app.get('/', (_, res) => {
res.type('json');
res.status(200).send(serverInfo);
res.status(200).send(env.envFile ? getServerInfo() : serverInfo);
})
app.get('/favicon.ico', (req, res) => {
@@ -299,17 +337,26 @@ export const runAPI = (express, app, __dirname) => {
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) {
if (env.freebindCIDR) {
throw new Error('Freebind is not available when external proxy is enabled')
options.httpProxy = env.externalProxy;
}
setGlobalDispatcher(new ProxyAgent(env.externalProxy))
}
setGlobalDispatcher(
new EnvHttpProxyAgent(options)
);
});
app.listen(env.apiPort, env.listenAddress, () => {
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" +
Bright(Cyan("cobalt ")) + Bright("API ^ω^") + "\n" +
"~~~~~~\n" +
Bright("version: ") + version + "\n" +
@@ -321,6 +368,21 @@ export const runAPI = (express, app, __dirname) => {
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

@@ -1,16 +1,36 @@
function t(color, tt) {
return color + tt + "\x1b[0m"
const ANSI = {
RESET: "\x1b[0m",
BRIGHT: "\x1b[1m",
RED: "\x1b[31m",
GREEN: "\x1b[32m",
CYAN: "\x1b[36m",
YELLOW: "\x1b[93m"
}
export function Bright(tt) {
return t("\x1b[1m", tt)
function wrap(color, text) {
if (!ANSI[color.toUpperCase()]) {
throw "invalid color";
}
return ANSI[color.toUpperCase()] + text + ANSI.RESET;
}
export function Red(tt) {
return t("\x1b[31m", tt)
export function Bright(text) {
return wrap('bright', text);
}
export function Green(tt) {
return t("\x1b[32m", tt)
export function Red(text) {
return wrap('red', text);
}
export function Cyan(tt) {
return t("\x1b[36m", tt)
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

@@ -23,6 +23,15 @@ export async function runTest(url, params, expect) {
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) {

View File

@@ -1,55 +1,43 @@
const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '='];
import { request } from "undici";
const redirectStatuses = new Set([301, 302, 303, 307, 308]);
export function metadataManager(obj) {
const keys = Object.keys(obj);
const tags = [
"album",
"copyright",
"title",
"artist",
"track",
"date"
]
let commands = []
export async function getRedirectingURL(url, dispatcher, headers) {
const params = {
dispatcher,
method: 'HEAD',
headers,
redirect: 'manual'
};
const getParams = {
...params,
method: 'GET',
};
for (const i in keys) {
if (tags.includes(keys[i]))
commands.push('-metadata', `${keys[i]}=${obj[keys[i]]}`)
const callback = (r) => {
if (redirectStatuses.has(r.statusCode) && r.headers['location']) {
return r.headers['location'];
}
}
return commands;
}
export function cleanString(string) {
for (const i in forbiddenCharsString) {
string = string.replaceAll("/", "_")
.replaceAll(forbiddenCharsString[i], '')
}
return string;
}
export function verifyLanguageCode(code) {
const langCode = String(code.slice(0, 2).toLowerCase());
if (RegExp(/[a-z]{2}/).test(code)) {
return langCode
}
return "en"
}
export function languageCode(req) {
if (req.header('Accept-Language')) {
return verifyLanguageCode(req.header('Accept-Language'))
}
return "en"
}
export function cleanHTML(html) {
let clean = html.replace(/ {4}/g, '');
clean = clean.replace(/\n/g, '');
return clean
}
/*
try request() with HEAD & GET,
then do the same with fetch
(fetch is required for shortened reddit links)
*/
export function getRedirectingURL(url) {
return fetch(url, { redirect: 'manual' }).then((r) => {
if ([301, 302, 303].includes(r.status) && r.headers.has('location'))
return r.headers.get('location');
}).catch(() => null);
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) {
@@ -65,3 +53,27 @@ export function merge(a, b) {
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

@@ -1,50 +1,145 @@
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 { env } from '../../config.js';
import * as cluster from '../../misc/cluster.js';
import { isCluster } from '../../config.js';
const WRITE_INTERVAL = 60000,
cookiePath = env.cookiePath,
COUNTER = Symbol('counter');
const WRITE_INTERVAL = 60000;
const VALID_SERVICES = new Set([
'instagram',
'instagram_bearer',
'reddit',
'twitter',
'youtube',
'vimeo_bearer',
]);
const invalidCookies = {};
let cookies = {}, dirty = false, intervalId;
const setup = async () => {
try {
if (!cookiePath) return;
cookies = await readFile(cookiePath, 'utf8');
cookies = JSON.parse(cookies);
intervalId = setInterval(writeChanges, WRITE_INTERVAL)
} catch { /* no cookies for you */ }
}
setup();
function writeChanges() {
function writeChanges(cookiePath) {
if (!dirty) return;
dirty = false;
writeFile(cookiePath, JSON.stringify(cookies, null, 4)).catch(() => {
clearInterval(intervalId)
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;
})
}
export function getCookie(service) {
if (!cookies[service] || !cookies[service].length) return;
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;
let n;
if (cookies[service][COUNTER] === undefined) {
n = cookies[service][COUNTER] = 0
} else {
++cookies[service][COUNTER]
n = (cookies[service][COUNTER] %= cookies[service].length)
invalidCookies[serviceName] = cookies[serviceName];
delete cookies[serviceName];
}
const cookie = cookies[service][n];
if (typeof cookie === 'string') cookies[service][n] = Cookie.fromString(cookie);
if (!intervalId) {
intervalId = setInterval(() => writeChanges(cookiePath), WRITE_INTERVAL);
}
return cookies[service][n]
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) {
@@ -57,10 +152,6 @@ export function updateCookie(cookie, headers) {
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);
}
export function updateCookieValues(cookie, values) {
cookie.set(values);
if (Object.keys(values).length) dirty = true
}

View File

@@ -1,3 +1,28 @@
// 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 = '';
@@ -5,7 +30,11 @@ export default (f, style, isAudioOnly, isAudioMuted) => {
let classicTags = [...infoBase];
let basicTags = [];
const title = `${f.title} - ${f.author}`;
let title = sanitizeString(f.title);
if (f.author) {
title += ` - ${sanitizeString(f.author)}`;
}
if (f.resolution) {
classicTags.push(f.resolution);

View File

@@ -0,0 +1,81 @@
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) => {
if (!sessionResponse.potoken) {
throw "no poToken in session response";
}
if (!sessionResponse.visitor_data) {
throw "no visitor_data in session response";
}
if (!sessionResponse.updated) {
throw "no last update timestamp in session response";
}
// https://github.com/iv-org/youtube-trusted-session-generator/blob/c2dfe3f/potoken_generator/main.py#L25
if (sessionResponse.potoken.length < 160) {
console.error(`${Yellow('[!]')} poToken is too short and might not work (${new Date().toISOString()})`);
}
}
const updateSession = (newSession) => {
session = newSession;
}
const loadSession = async () => {
const sessionServerUrl = new URL(env.ytSessionServer);
sessionServerUrl.pathname = "/token";
const newSession = await fetch(
sessionServerUrl,
{ 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

@@ -3,27 +3,48 @@ 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";
export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disableMetadata, filenameStyle, twitterGif, requestIP, audioBitrate, alwaysProxy }) {
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 = {
u: r.urls,
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
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 && twitterGif) action = "gif";
else if (r.isGif && convertGif) action = "gif";
else if (isAudioOnly) action = "audio";
else if (isAudioMuted) action = "muteVideo";
else if (r.isM3U8) action = "m3u8";
else if (r.isHLS) action = "hls";
else action = "video";
if (action === "picker" || action === "audio") {
@@ -32,10 +53,11 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
}
if (action === "muteVideo" && isAudioMuted && !r.filenameAttributes) {
const parts = r.filename.split(".");
const ext = parts.pop();
defaultParams.filename = `${parts.join(".")}_mute.${ext}`;
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) {
@@ -45,27 +67,29 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
});
case "photo":
responseType = "redirect";
params = { type: "proxy" };
break;
case "gif":
params = { type: "gif" };
break;
case "m3u8":
case "hls":
params = {
type: Array.isArray(r.urls) ? "merge" : "remux"
type: Array.isArray(r.urls) ? "merge" : "remux",
isHLS: true,
}
break;
case "muteVideo":
let muteType = "mute";
if (Array.isArray(r.urls) && !r.isM3U8) {
if (Array.isArray(r.urls) && !r.isHLS) {
muteType = "proxy";
}
params = {
type: muteType,
u: Array.isArray(r.urls) ? r.urls[0] : r.urls
url: Array.isArray(r.urls) ? r.urls[0] : r.urls,
isHLS: r.isHLS
}
if (host === "reddit" && r.typeId === "redirect") {
responseType = "redirect";
@@ -79,6 +103,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
case "twitter":
case "snapchat":
case "bsky":
case "xiaohongshu":
params = { picker: r.picker };
break;
@@ -90,14 +115,15 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
}
params = {
picker: r.picker,
u: createStream({
url: createStream({
service: "tiktok",
type: audioStreamType,
u: r.urls,
url: r.urls,
headers: r.headers,
filename: r.audioFilename,
filename: `${r.audioFilename}.${audioFormat}`,
isAudioOnly: true,
audioFormat,
audioBitrate
})
}
break;
@@ -121,7 +147,9 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
case "vimeo":
if (Array.isArray(r.urls)) {
params = { type: "merge" }
params = { type: "merge" };
} else if (r.subtitles) {
params = { type: "remux" };
} else {
responseType = "redirect";
}
@@ -135,19 +163,33 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
}
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 "vine":
case "instagram":
case "tumblr":
case "pinterest":
case "streamable":
case "snapchat":
case "loom":
case "twitch":
responseType = "redirect";
break;
@@ -155,9 +197,9 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
break;
case "audio":
if (audioIgnore.includes(host) || (host === "reddit" && r.typeId === "redirect")) {
if (audioIgnore.has(host) || (host === "reddit" && r.typeId === "redirect")) {
return createResponse("error", {
code: "error.api.fetch.empty"
code: "error.api.service.audio_not_supported"
})
}
@@ -181,18 +223,20 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
}
}
if (r.isM3U8 || host === "vimeo") {
if (r.isHLS || host === "vimeo") {
copy = false;
processType = "audio";
}
params = {
type: processType,
u: Array.isArray(r.urls) ? r.urls[1] : r.urls,
url: Array.isArray(r.urls) ? r.urls[1] : r.urls,
audioBitrate,
audioCopy: copy,
audioFormat,
isHLS: r.isHLS,
}
break;
}
@@ -201,10 +245,39 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
defaultParams.filename += `.${audioFormat}`;
}
// alwaysProxy is set to true in match.js if localProcessing is forced
if (alwaysProxy && responseType === "redirect") {
responseType = "tunnel";
params.type = "proxy";
}
return createResponse(responseType, {...defaultParams, ...params})
// 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 }
);
}

View File

@@ -19,7 +19,6 @@ 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 vine from "./services/vine.js";
import pinterest from "./services/pinterest.js";
import streamable from "./services/streamable.js";
import twitch from "./services/twitch.js";
@@ -29,10 +28,12 @@ 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 }) {
export default async function({ host, patternMatch, params, authType }) {
const { url } = params;
assert(url instanceof URL);
let dispatcher, requestIP;
@@ -65,22 +66,36 @@ export default async function({ host, patternMatch, params }) {
});
}
// 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.twitterGif,
toGif: !!params.convertGif,
alwaysProxy: params.alwaysProxy,
dispatcher
dispatcher,
subtitleLang
});
break;
case "vk":
r = await vk({
userId: patternMatch.userId,
ownerId: patternMatch.ownerId,
videoId: patternMatch.videoId,
quality: params.videoQuality
accessKey: patternMatch.accessKey,
quality: params.videoQuality,
subtitleLang,
});
break;
@@ -97,20 +112,27 @@ export default async function({ host, patternMatch, params }) {
case "youtube":
let fetchInfo = {
dispatcher,
id: patternMatch.id.slice(0, 11),
quality: params.videoQuality,
format: params.youtubeVideoCodec,
codec: params.youtubeVideoCodec,
container: params.youtubeVideoContainer,
isAudioOnly,
isAudioMuted,
dubLang: params.youtubeDubLang,
dispatcher
youtubeHLS,
subtitleLang,
}
if (url.hostname === "music.youtube.com" || isAudioOnly) {
fetchInfo.quality = "max";
fetchInfo.format = "vp9";
fetchInfo.quality = "1080";
fetchInfo.codec = "vp9";
fetchInfo.isAudioOnly = true;
fetchInfo.isAudioMuted = false;
if (env.ytAllowBetterAudio && params.youtubeBetterAudio) {
fetchInfo.quality = "max";
}
}
r = await youtube(fetchInfo);
@@ -118,20 +140,20 @@ export default async function({ host, patternMatch, params }) {
case "reddit":
r = await reddit({
sub: patternMatch.sub,
id: patternMatch.id,
user: patternMatch.user
...patternMatch,
dispatcher,
});
break;
case "tiktok":
r = await tiktok({
postId: patternMatch.postId,
id: patternMatch.id,
shortLink: patternMatch.shortLink,
fullAudio: params.tiktokFullAudio,
isAudioOnly,
h265: params.tiktokH265,
h265: params.allowH265,
alwaysProxy: params.alwaysProxy,
subtitleLang,
});
break;
@@ -149,6 +171,7 @@ export default async function({ host, patternMatch, params }) {
password: patternMatch.password,
quality: params.videoQuality,
isAudioOnly,
subtitleLang,
});
break;
@@ -156,12 +179,8 @@ export default async function({ host, patternMatch, params }) {
isAudioOnly = true;
isAudioMuted = false;
r = await soundcloud({
url,
author: patternMatch.author,
song: patternMatch.song,
...patternMatch,
format: params.audioFormat,
shortLink: patternMatch.shortLink || false,
accessKey: patternMatch.accessKey || false
});
break;
@@ -174,12 +193,6 @@ export default async function({ host, patternMatch, params }) {
})
break;
case "vine":
r = await vine({
id: patternMatch.id
});
break;
case "pinterest":
r = await pinterest({
id: patternMatch.id,
@@ -210,6 +223,7 @@ export default async function({ host, patternMatch, params }) {
key: patternMatch.key,
quality: params.videoQuality,
isAudioOnly,
subtitleLang,
});
break;
@@ -226,20 +240,39 @@ export default async function({ host, patternMatch, params }) {
case "loom":
r = await loom({
id: patternMatch.id
id: patternMatch.id,
subtitleLang,
});
break;
case "facebook":
r = await facebook({
...patternMatch
...patternMatch,
dispatcher
});
break;
case "bsky":
r = await bluesky({
...patternMatch,
alwaysProxy: params.alwaysProxy
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;
@@ -265,7 +298,7 @@ export default async function({ host, patternMatch, params }) {
switch(r.error) {
case "content.too_long":
context = {
limit: env.durationLimit / 60,
limit: parseFloat((env.durationLimit / 60).toFixed(2)),
}
break;
@@ -286,6 +319,15 @@ export default async function({ host, patternMatch, params }) {
})
}
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,
@@ -294,10 +336,11 @@ export default async function({ host, patternMatch, params }) {
isAudioMuted,
disableMetadata: params.disableMetadata,
filenameStyle: params.filenameStyle,
twitterGif: params.twitterGif,
convertGif: params.convertGif,
requestIP,
audioBitrate: params.audioBitrate,
alwaysProxy: params.alwaysProxy,
alwaysProxy: params.alwaysProxy || localProcessing === "forced",
localProcessing,
})
} catch {
return createResponse("error", {

View File

@@ -1,7 +1,8 @@
import mime from "mime";
import ipaddr from "ipaddr.js";
import { createStream } from "../stream/manage.js";
import { apiSchema } from "./schema.js";
import { createProxyTunnels, createStream } from "../stream/manage.js";
export function createResponse(responseType, responseData) {
const internalError = (code) => {
@@ -10,7 +11,7 @@ export function createResponse(responseType, responseData) {
body: {
status: "error",
error: {
code: code || "error.api.fetch.critical",
code: code || "error.api.fetch.critical.core",
},
critical: true
}
@@ -37,7 +38,7 @@ export function createResponse(responseType, responseData) {
case "redirect":
response = {
url: responseData?.u,
url: responseData?.url,
filename: responseData?.filename
}
break;
@@ -49,10 +50,48 @@ export function createResponse(responseType, responseData) {
}
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?.u,
audio: responseData?.url,
audioFilename: responseData?.filename
}
break;
@@ -72,24 +111,28 @@ export function createResponse(responseType, responseData) {
}
}
} catch {
return internalError()
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) {
export function getIP(req, prefix = 56) {
const strippedIP = req.ip.replace(/^::ffff:/, '');
const ip = ipaddr.parse(strippedIP);
if (ip.kind() === 'ipv4') {
return strippedIP;
}
const prefix = 56;
const v6Bytes = ip.toByteArray();
v6Bytes.fill(0, prefix / 8);

View File

@@ -1,7 +1,5 @@
import { z } from "zod";
import { normalizeURL } from "./url.js";
import { verifyLanguageCode } from "../misc/utils.js";
export const apiSchema = z.object({
url: z.string()
@@ -22,26 +20,45 @@ export const apiSchema = z.object({
filenameStyle: z.enum(
["classic", "pretty", "basic", "nerdy"]
).default("classic"),
).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()
.length(2)
.transform(verifyLanguageCode)
.min(2)
.max(8)
.regex(/^[0-9a-zA-Z\-]+$/)
.optional(),
subtitleLang: z.string()
.min(2)
.max(8)
.regex(/^[0-9a-zA-Z\-]+$/)
.optional(),
alwaysProxy: z.boolean().default(false),
disableMetadata: z.boolean().default(false),
allowH265: z.boolean().default(false),
convertGif: z.boolean().default(true),
tiktokFullAudio: z.boolean().default(false),
tiktokH265: z.boolean().default(false),
twitterGif: z.boolean().default(true),
youtubeDubBrowserLang: z.boolean().default(false),
alwaysProxy: z.boolean().default(false),
youtubeHLS: z.boolean().default(false),
youtubeBetterAudio: z.boolean().default(false),
})
.strict();

View File

@@ -1,5 +1,6 @@
const friendlyNames = {
bsky: "bluesky",
twitch: "twitch clips"
}
export const friendlyServiceName = (service) => {

View File

@@ -1,12 +1,13 @@
import UrlPattern from "url-pattern";
export const audioIgnore = ["vk", "ok", "loom"];
export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky"];
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"
@@ -30,23 +31,35 @@ export const services = {
"reel/:id",
"share/:shareType/:id"
],
subdomains: ["web"],
subdomains: ["web", "m"],
altDomains: ["fb.watch"],
},
instagram: {
patterns: [
"reels/:postId",
":username/reel/:postId",
"reel/:postId",
"p/:postId",
":username/p/:postId",
"tv/:postId",
"stories/:username/:storyId"
"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"],
patterns: ["share/:id", "embed/:id"],
},
ok: {
patterns: [
@@ -62,10 +75,31 @@ export const services = {
"url_shortener/:shortLink"
],
},
newgrounds: {
patterns: [
"portal/view/:id",
"audio/listen/:audioId",
]
},
reddit: {
patterns: [
"comments/:id",
"r/:sub/comments/:id",
"r/:sub/comments/:id/:title",
"user/:user/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: "*",
},
@@ -89,6 +123,7 @@ export const services = {
"add/:username",
"u/:username",
"t/:shortLink",
"o/:spotlightId",
],
subdomains: ["t", "story"],
},
@@ -111,12 +146,13 @@ export const services = {
tiktok: {
patterns: [
":user/video/:postId",
":id",
"t/:id",
"i18n/share/video/:postId",
":shortLink",
"t/:shortLink",
":user/photo/:postId",
"v/:id.html"
"v/:postId.html"
],
subdomains: ["vt", "vm", "m"],
subdomains: ["vt", "vm", "m", "t"],
},
tumblr: {
patterns: [
@@ -130,6 +166,7 @@ export const services = {
twitch: {
patterns: [":channel/clip/:clip"],
tld: "tv",
subdomains: ["clips", "www", "m"],
},
twitter: {
patterns: [
@@ -137,37 +174,51 @@ export const services = {
":user/status/:id/video/:index",
":user/status/:id/photo/:index",
":user/status/:id/mediaviewer",
":user/status/:id/mediaViewer"
":user/status/:id/mediaViewer",
"i/bookmarks?post_id=:id"
],
subdomains: ["mobile"],
altDomains: ["x.com", "vxtwitter.com", "fixvx.com"],
},
vine: {
patterns: ["v/:id"],
tld: "co",
},
vimeo: {
patterns: [
":id",
"video/:id",
":id/:password",
"/channels/:user/:id"
"/channels/:user/:id",
"groups/:groupId/videos/:id"
],
subdomains: ["player"],
},
vk: {
patterns: [
"video:userId_:videoId",
"clip:userId_:videoId",
"clips:duplicate?z=clip:userId_:videoId"
"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"
"watch/:id",
"v/:id"
],
subdomains: ["music", "m"],
}
@@ -176,7 +227,7 @@ export const services = {
Object.values(services).forEach(service => {
service.patterns = service.patterns.map(
pattern => new UrlPattern(pattern, {
segmentValueCharset: UrlPattern.defaultOptions.segmentValueCharset + '@\\.'
segmentValueCharset: UrlPattern.defaultOptions.segmentValueCharset + '@\\.:'
})
)
})

View File

@@ -1,49 +1,72 @@
export const testers = {
"bilibili": pattern =>
pattern.comId?.length <= 12 || pattern.comShortLink?.length <= 16
|| pattern.tvId?.length <= 24,
(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 <= 12
|| (pattern.username?.length <= 30 && pattern.storyId?.length <= 24),
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,
pattern.id?.length <= 128 ||
pattern.shortLink?.length <= 32,
"reddit": pattern =>
(pattern.sub?.length <= 22 && pattern.id?.length <= 10)
|| (pattern.user?.length <= 22 && pattern.id?.length <= 10),
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,
"soundcloud": pattern =>
(pattern.author?.length <= 255 && pattern.song?.length <= 255)
|| pattern.shortLink?.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,
(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,
pattern.id?.length <= 6,
"tiktok": pattern =>
pattern.postId?.length <= 21 || pattern.id?.length <= 13,
pattern.postId?.length <= 21 ||
pattern.shortLink?.length <= 21,
"tumblr": pattern =>
pattern.id?.length < 21
|| (pattern.id?.length < 21 && pattern.user?.length <= 32),
pattern.id?.length < 21 ||
(pattern.id?.length < 21 && pattern.user?.length <= 32),
"twitch": pattern =>
pattern.channel && pattern.clip?.length <= 100,
@@ -52,25 +75,16 @@ export const testers = {
pattern.id?.length < 20,
"vimeo": pattern =>
pattern.id?.length <= 11
&& (!pattern.password || pattern.password.length < 16),
"vine": pattern =>
pattern.id?.length <= 12,
pattern.id?.length <= 11 && (!pattern.password || pattern.password.length < 16),
"vk": pattern =>
pattern.userId?.length <= 10 && pattern.videoId?.length <= 10,
(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,
"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,
"bsky": pattern =>
pattern.user?.length <= 128 && pattern.post?.length <= 128,
}

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,8 +17,14 @@ function extractBestQuality(dashData) {
return [ bestVideo, bestAudio ];
}
async function com_download(id) {
let html = await fetch(`https://bilibili.com/video/${id}`, {
async function com_download(id, partId) {
const url = new URL(`https://bilibili.com/video/${id}`);
if (partId) {
url.searchParams.set('p', partId);
}
const html = await fetch(url, {
headers: {
"user-agent": genericUserAgent
}
@@ -45,7 +40,10 @@ async function com_download(id) {
return { error: "fetch.empty" };
}
let streamData = JSON.parse(html.split('<script>window.__playinfo__=')[1].split('</script>')[0]);
const streamData = JSON.parse(
html.split('<script>window.__playinfo__=')[1].split('</script>')[0]
);
if (streamData.data.timelength > env.durationLimit * 1000) {
return { error: "content.too_long" };
}
@@ -55,10 +53,15 @@ async function com_download(id) {
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`,
};
}
@@ -97,13 +100,14 @@ 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);
}

View File

@@ -2,12 +2,19 @@ import HLS from "hls-parser";
import { cobaltUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
const extractVideo = async ({ getPost, filename }) => {
const urlMasterHLS = getPost?.thread?.post?.embed?.playlist;
if (!urlMasterHLS) return { error: "fetch.empty" };
if (!urlMasterHLS.startsWith("https://video.bsky.app/")) return { error: "fetch.empty" };
const extractVideo = async ({ media, filename, dispatcher }) => {
let urlMasterHLS = media?.playlist;
const masterHLS = await fetch(urlMasterHLS)
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();
@@ -26,7 +33,7 @@ const extractVideo = async ({ getPost, filename }) => {
urls: videoURL,
filename: `${filename}.mp4`,
audioFilename: `${filename}_audio`,
isM3U8: true,
isHLS: true,
}
}
@@ -48,7 +55,7 @@ const extractImages = ({ getPost, filename, alwaysProxy }) => {
let proxiedImage = createStream({
service: "bluesky",
type: "proxy",
u: url,
url,
filename: `${filename}_${i + 1}.jpg`,
});
@@ -64,7 +71,25 @@ const extractImages = ({ getPost, filename, alwaysProxy }) => {
return { picker };
}
export default async function ({ user, post, alwaysProxy }) {
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",
@@ -73,20 +98,59 @@ export default async function ({ user, post, alwaysProxy }) {
const getPost = await fetch(apiEndpoint, {
headers: {
"user-agent": cobaltUserAgent
}
"user-agent": cobaltUserAgent,
},
dispatcher
}).then(r => r.json()).catch(() => {});
if (!getPost || getPost?.error) return { error: "fetch.empty" };
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}`;
if (embedType === "app.bsky.embed.video#view") {
return extractVideo({ getPost, filename });
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,
});
}
if (embedType === "app.bsky.embed.images#view") {
return extractImages({ getPost, filename, alwaysProxy });
return extractVideo({
media: getPost.thread?.post?.embed?.media,
filename,
});
}
return { error: "fetch.empty" };

View File

@@ -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,13 +23,13 @@ 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);

View File

@@ -1,3 +1,5 @@
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";
@@ -8,6 +10,7 @@ const commonHeaders = {
"sec-fetch-site": "same-origin",
"x-ig-app-id": "936619743392459"
}
const mobileHeaders = {
"x-ig-app-locale": "en_US",
"x-ig-device-locale": "en_US",
@@ -19,6 +22,7 @@ const mobileHeaders = {
"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",
@@ -33,7 +37,7 @@ const embedHeaders = {
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"User-Agent": genericUserAgent,
}
const cachedDtsg = {
@@ -41,7 +45,17 @@ const cachedDtsg = {
expiry: 0
}
export default function(obj) {
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) {
@@ -91,6 +105,7 @@ export default function(obj) {
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}/`);
@@ -119,6 +134,7 @@ export default function(obj) {
return mediaInfo?.items?.[0];
}
async function requestHTML(id, cookie) {
const data = await fetch(`https://www.instagram.com/p/${id}/embed/captioned/`, {
headers: {
@@ -136,40 +152,167 @@ export default function(obj) {
return embedData;
}
async function requestGQL(id, cookie) {
let dtsgId;
if (cookie) {
dtsgId = await findDtsgId(cookie);
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),
}
};
}
const url = new URL('https://www.instagram.com/api/graphql/');
const requestData = {
jazoest: '26406',
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,
__relay_internal__pv__PolarisShareMenurelayprovider: false
fetch_tagged_user_count: null,
hoisted_comment_id: null,
hoisted_reply_id: null
}),
doc_id: '7153618348081770'
server_timestamps: true,
doc_id: '8845758582119845'
}).toString()
});
return {
gql_data: await req.json()
.then(r => r.data)
.catch(() => null)
};
if (dtsgId) {
requestData.fb_dtsg = dtsgId;
}
return (await request(url, cookie, 'POST', requestData))
.data
?.xdt_api__v1__media__shortcode__web_info
?.items
?.[0];
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 sidecar = data?.gql_data?.shortcode_media?.edge_sidecar_to_children;
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 ? "video" : "photo";
const url = type === "video" ? e.node?.video_url : e.node?.display_url;
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";
@@ -177,7 +320,7 @@ export default function(obj) {
if (alwaysProxy) proxyFile = createStream({
service: "instagram",
type: "proxy",
u: url,
url,
filename: `instagram_${id}_${i + 1}.${itemExt}`
});
@@ -189,23 +332,28 @@ export default function(obj) {
thumb: createStream({
service: "instagram",
type: "proxy",
u: e.node?.display_url,
url: e.node?.display_url,
filename: `instagram_${id}_${i + 1}.jpg`
})
}
});
if (picker.length) return { picker }
} else if (data?.gql_data?.shortcode_media?.video_url) {
}
if (shortcodeMedia?.video_url) {
return {
urls: data.gql_data.shortcode_media.video_url,
urls: shortcodeMedia.video_url,
filename: `instagram_${id}.mp4`,
audioFilename: `instagram_${id}_audio`
}
} else if (data?.gql_data?.shortcode_media?.display_url) {
}
if (shortcodeMedia?.display_url) {
return {
urls: data.gql_data?.shortcode_media.display_url,
isPhoto: true
urls: shortcodeMedia.display_url,
isPhoto: true,
filename: `instagram_${id}.jpg`,
}
}
}
@@ -230,7 +378,7 @@ export default function(obj) {
if (alwaysProxy) proxyFile = createStream({
service: "instagram",
type: "proxy",
u: url,
url,
filename: `instagram_${id}_${i + 1}.${itemExt}`
});
@@ -242,7 +390,7 @@ export default function(obj) {
thumb: createStream({
service: "instagram",
type: "proxy",
u: imageUrl,
url: imageUrl,
filename: `instagram_${id}_${i + 1}.jpg`
})
}
@@ -266,6 +414,9 @@ export default function(obj) {
}
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');
@@ -282,19 +433,21 @@ export default function(obj) {
if (media_id && token) data = await requestMobileApi(media_id, { token });
// mobile api (no cookie, cookie)
if (media_id && !data) data = await requestMobileApi(media_id);
if (media_id && cookie && !data) data = await requestMobileApi(media_id, { 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 (!data) data = await requestHTML(id);
if (!data && cookie) data = await requestHTML(id, 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 (!data) data = await requestGQL(id);
if (!data && cookie) data = await requestGQL(id, cookie);
if (!hasData(data)) data = await requestGQL(id);
if (!hasData(data) && cookie) data = await requestGQL(id, cookie);
} catch {}
if (!data) return { error: "fetch.fail" };
if (!hasData(data)) {
return getErrorContext(id);
}
if (data?.gql_data) {
result = extractOldPost(data, id, alwaysProxy)
@@ -357,14 +510,30 @@ export default function(obj) {
if (item.image_versions2?.candidates) {
return {
urls: item.image_versions2.candidates[0].url,
isPhoto: true
isPhoto: true,
filename: `instagram_${id}.jpg`,
}
}
return { error: "link.unsupported" };
}
const { postId, storyId, username, alwaysProxy } = obj;
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);

View File

@@ -1,18 +1,18 @@
import { genericUserAgent } from "../../config.js";
export default async function({ id }) {
const gql = await fetch(`https://www.loom.com/api/campaigns/sessions/${id}/transcoded-url`, {
method: "POST",
headers: {
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",
});
"apollographql-client-name": "web",
"apollographql-client-version": "14c0b42",
"x-loom-request-source": "loom_web_14c0b42",
},
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,
@@ -20,20 +20,89 @@ export default async function({ id }) {
deviceID: null
})
})
.then(r => r.status === 200 ? r.json() : false)
.then(r => r.status === 200 && r.json())
.catch(() => {});
if (!gql) return { error: "fetch.empty" };
if (gql?.url?.includes('.mp4?')) {
return gql.url;
}
}
const videoUrl = 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;
}
if (videoUrl?.includes('.mp4?')) {
return {
urls: videoUrl,
urls: url,
subtitles,
filename: `loom_${id}.mp4`,
audioFilename: `loom_${id}_audio`
}
}
return { error: "fetch.empty" }
}

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 "../../misc/utils.js";
const resolutions = {
"ultra": "2160",
@@ -44,8 +43,8 @@ export default async function(o) {
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 {

View File

@@ -1,16 +1,18 @@
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) {
id = await fetch(`https://api.pinterest.com/url_shortener/${o.shortLink}/redirect/`, { redirect: "manual" })
.then(r => r.headers.get("location").split('pin/')[1].split('/')[0])
.catch(() => {});
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" };
@@ -18,16 +20,20 @@ export default async function(o) {
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') && a.includes('720p'));
.find(a => a.endsWith('.mp4'));
if (videoLink) return {
urls: videoLink,
filename: `pinterest_${o.id}.mp4`,
audioFilename: `pinterest_${o.id}_audio`
filename: `pinterest_${id}.mp4`,
audioFilename: `pinterest_${id}_audio`
}
const imageLink = [...html.matchAll(imageRegex)]
@@ -39,7 +45,7 @@ export default async function(o) {
if (imageLink) return {
urls: imageLink,
isPhoto: true,
filename: `pinterest_${o.id}.${imageType}`
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";
@@ -48,23 +49,36 @@ 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)) {
@@ -73,12 +87,17 @@ export default async function(obj) {
data = data[0]?.data?.children[0]?.data;
const id = `${String(obj.sub).toLowerCase()}_${obj.id}`;
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,
filename: `reddit_${id}.gif`,
filename: `reddit_${sourceId}.gif`,
}
if (!data.secure_media?.reddit_video)
@@ -87,8 +106,9 @@ export default async function(obj) {
if (data.secure_media?.reddit_video?.duration > env.durationLimit)
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')) {
@@ -121,7 +141,7 @@ export default async function(obj) {
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 { env } from "../../config.js";
import { cleanString } from "../../misc/utils.js";
async function requestJSON(url) {
try {
@@ -35,6 +33,10 @@ export default async function(obj) {
const play = await requestJSON(requestURL);
if (!play) return { error: "fetch.fail" };
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" };
@@ -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

@@ -1,7 +1,6 @@
import { extract, normalizeURL } from "../url.js";
import { resolveRedirectingURL } from "../url.js";
import { genericUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
import { getRedirectingURL } from "../../misc/utils.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>$/;
@@ -41,9 +40,9 @@ async function getStory(username, storyId, alwaysProxy) {
const nextDataString = html.match(NEXT_DATA_REGEX)?.[1];
if (nextDataString) {
const data = JSON.parse(nextDataString);
const storyIdParam = data.query.profileParams[1];
const storyIdParam = data?.query?.profileParams?.[1];
if (storyIdParam && data.props.pageProps.story) {
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) {
@@ -62,7 +61,7 @@ async function getStory(username, storyId, alwaysProxy) {
}
}
const defaultStory = data.props.pageProps.curatedHighlights[0];
const defaultStory = data?.props?.pageProps?.curatedHighlights?.[0];
if (defaultStory) {
return {
picker: defaultStory.snapList.map(snap => {
@@ -73,7 +72,7 @@ async function getStory(username, storyId, alwaysProxy) {
const proxy = createStream({
service: "snapchat",
type: "proxy",
u: snapUrl,
url: snapUrl,
filename: `snapchat_${username}_${snap.timestampInSec.value}.${snapExt}`,
});
@@ -81,7 +80,7 @@ async function getStory(username, storyId, alwaysProxy) {
if (snapType === "video") thumbProxy = createStream({
service: "snapchat",
type: "proxy",
u: snap.snapUrls.mediaPreviewUrl.value,
url: snap.snapUrls.mediaPreviewUrl.value,
});
if (alwaysProxy) snapUrl = proxy;
@@ -100,24 +99,13 @@ async function getStory(username, storyId, alwaysProxy) {
export default async function (obj) {
let params = obj;
if (obj.shortLink) {
const link = await getRedirectingURL(`https://t.snapchat.com/${obj.shortLink}`);
if (!link?.startsWith('https://www.snapchat.com/')) {
return { error: "fetch.short_link" };
params = await resolveRedirectingURL(`https://t.snapchat.com/${obj.shortLink}`);
}
const extractResult = extract(normalizeURL(link));
if (extractResult?.host !== 'snapchat') {
return { error: "fetch.short_link" };
}
params = extractResult.patternMatch;
}
if (params.spotlightId) {
if (params?.spotlightId) {
const result = await getSpotlight(params.spotlightId);
if (result) return result;
} else if (params.username) {
} else if (params?.username) {
const result = await getStory(params.username, params.storyId, obj.alwaysProxy);
if (result) return result;
}

View File

@@ -1,5 +1,5 @@
import { env } from "../../config.js";
import { cleanString } from "../../misc/utils.js";
import { resolveRedirectingURL } from "../url.js";
const cachedID = {
version: '',
@@ -8,22 +8,25 @@ const cachedID = {
async function findClientID() {
try {
let sc = await fetch('https://soundcloud.com/').then(r => r.text()).catch(() => {});
let scVersion = String(sc.match(/<script>window\.__sc_version="[0-9]{10}"<\/script>/)[0].match(/[0-9]{10}/));
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;
if (cachedID.version === scVersion) {
return cachedID.id;
}
const scripts = sc.matchAll(/<script.+src="(.+)">/g);
let scripts = sc.matchAll(/<script.+src="(.+)">/g);
let clientid;
for (let script of scripts) {
let url = script[1];
const url = script[1];
if (!url?.startsWith('https://a-v2.sndcdn.com/')) {
return;
}
let scrf = await fetch(url).then(r => r.text()).catch(() => {});
let id = scrf.match(/\("client_id=[A-Za-z0-9]{32}"\)/);
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];
@@ -37,37 +40,79 @@ async function findClientID() {
} 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) {
let clientId = await findClientID();
const clientId = await findClientID();
if (!clientId) return { error: "fetch.fail" };
let link;
if (obj.url.hostname === 'on.soundcloud.com' && obj.shortLink) {
link = await fetch(`https://on.soundcloud.com/${obj.shortLink}/`, { redirect: "manual" }).then(r => {
if (r.status === 302 && r.headers.get("location").startsWith("https://soundcloud.com/")) {
return r.headers.get("location").split('?', 1)[0]
if (obj.shortLink) {
obj = {
...obj,
...await resolveRedirectingURL(
`https://on.soundcloud.com/${obj.shortLink}`
)
}
}).catch(() => {});
}
if (!link && obj.author && obj.song) {
link = `https://soundcloud.com/${obj.author}/${obj.song}${obj.accessKey ? `/s-${obj.accessKey}` : ''}`
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" };
let json = await fetch(`https://api-v2.soundcloud.com/resolve?url=${link}&client_id=${clientId}`)
.then(r => r.status === 200 ? r.json() : false)
.catch(() => {});
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.media.transcodings) return { error: "fetch.empty" };
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 = json.media.transcodings.find(v => v.preset === "opus_0_0"),
mp3Media = json.media.transcodings.find(v => v.preset === "mp3_0_0");
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)) {
@@ -75,35 +120,54 @@ export default async function(obj) {
bestAudio = "mp3"
}
let fileUrlBase = selectedStream.url;
let fileUrl = `${fileUrlBase}${fileUrlBase.includes("?") ? "&" : "?"}client_id=${clientId}&track_authorization=${json.track_authorization}`;
if (!fileUrl.startsWith("https://api-v2.soundcloud.com/media/soundcloud:tracks:"))
if (!selectedStream) {
return { error: "fetch.empty" };
if (json.duration > env.durationLimit * 1000) {
return { error: "content.too_long" };
}
let file = await fetch(fileUrl)
.then(async r => (await r.json()).url)
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" };
let fileMetadata = {
title: cleanString(json.title.trim()),
artist: cleanString(json.user.username.trim()),
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,
urls: file.toString(),
cover,
filenameAttributes: {
service: "soundcloud",
id: json.id,
title: fileMetadata.title,
author: fileMetadata.artist
...fileMetadata
},
bestAudio,
fileMetadata
fileMetadata,
isHLS: file.pathname.endsWith('.m3u8'),
}
}

View File

@@ -1,9 +1,10 @@
import Cookie from "../cookie/cookie.js";
import { extract } from "../url.js";
import { extract, normalizeURL } from "../url.js";
import { genericUserAgent } from "../../config.js";
import { updateCookie } from "../cookie/manager.js";
import { createStream } from "../../stream/manage.js";
import { convertLanguageCode } from "../../misc/language-codes.js";
const shortDomain = "https://vt.tiktok.com/";
@@ -12,7 +13,7 @@ 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]
@@ -23,14 +24,16 @@ export default async function(obj) {
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: "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,
@@ -44,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: "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}`,
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
@@ -78,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 }
}
@@ -102,7 +139,7 @@ export default async function(obj) {
if (obj.alwaysProxy) url = createStream({
service: "tiktok",
type: "proxy",
u: url,
url,
filename: `${filenameBase}_photo_${i + 1}.jpg`
})
@@ -131,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';

View File

@@ -1,5 +1,4 @@
import { env } from "../../config.js";
import { cleanString } from '../../misc/utils.js';
const gqlURL = "https://gql.twitch.tv/gql";
const clientIdHead = { "client-id": "kimne78kx3ncx6brgo4mv6wki5h1ko" };
@@ -73,13 +72,13 @@ export default async function (obj) {
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

@@ -1,13 +1,14 @@
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/I9GDzyCGZL2wSoYFFrrTVw/TweetResultByRestId';
const graphqlURL = 'https://api.x.com/graphql/4Siu98E55GquhG52zHdY5w/TweetDetail';
const tokenURL = 'https://api.x.com/1.1/guest/activate.json';
const tweetFeatures = JSON.stringify({"creator_subscriptions_tweet_preview_api_enabled":true,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"articles_preview_enabled":true,"tweetypie_unmention_optimization_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,"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,"rweb_video_timestamps_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"rweb_tipjar_consumption_enabled":true,"responsive_web_graphql_exclude_directive_enabled":true,"verified_phone_label_enabled":false,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_enhance_cards_enabled":false});
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});
const tweetFieldToggles = JSON.stringify({"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false});
const commonHeaders = {
"user-agent": genericUserAgent,
@@ -24,6 +25,11 @@ 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)
);
@@ -53,6 +59,25 @@ const getGuestToken = async (dispatcher, forceReload = false) => {
}
}
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);
@@ -75,10 +100,14 @@ const requestTweet = async(dispatcher, tweetId, token, cookie) => {
graphqlTweetURL.searchParams.set('variables',
JSON.stringify({
tweetId,
withCommunity: false,
includePromotedContent: false,
withVoice: false
focalTweetId: tweetId,
with_rux_injections: false,
rankingMode: "Relevance",
includePromotedContent: true,
withCommunity: true,
withQuickPromoteEligibilityTweetFields: true,
withBirdwatchNotes: true,
withVoice: true
})
);
graphqlTweetURL.searchParams.set('features', tweetFeatures);
@@ -87,53 +116,65 @@ const requestTweet = async(dispatcher, tweetId, token, cookie) => {
let result = await fetch(graphqlTweetURL, { headers, dispatcher });
updateCookie(cookie, result.headers);
// we might have been missing the `ct0` cookie, retry
// 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': cookie.values().ct0
'x-csrf-token': cookieValues.ct0
},
dispatcher
});
}
}
return result
}
export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
const cookie = await getCookie('twitter');
const parseCard = (cardOuter) => {
const card = JSON.parse(
(cardOuter?.legacy?.binding_values[0].value
|| cardOuter?.binding_values?.unified_card)?.string_value,
);
let guestToken = await getGuestToken(dispatcher);
if (!guestToken) return { error: "fetch.fail" };
let tweet = await requestTweet(dispatcher, id, guestToken);
// 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)
if (!["video_website", "image_website"].includes(card?.type)
|| !card?.media_entities
|| card?.component_objects?.media_1?.type !== "media") {
return;
}
tweet = await tweet.json();
const mediaId = card.component_objects?.media_1?.data?.id;
return [card.media_entities[mediaId]];
};
let tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
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") {
const reason = tweet?.data?.tweetResult?.result?.reason;
switch(reason) {
case "Protected":
return { error: "content.post.private" }
case "NsfwLoggedOut":
if (cookie) {
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
tweet = await tweet.json();
tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
} else return { error: "content.post.age" }
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);
}
}
@@ -141,8 +182,7 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
return { error: "content.post.unavailable" }
}
let tweetResult = tweet.data.tweetResult.result,
baseTweet = tweetResult.legacy,
let baseTweet = tweetResult.legacy,
repostedTweet = baseTweet?.retweeted_status_result?.result.legacy.extended_entities;
if (tweetTypename === "TweetWithVisibilityResults") {
@@ -150,7 +190,52 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
repostedTweet = baseTweet?.retweeted_status_result?.result.tweet.legacy.extended_entities;
}
let media = (repostedTweet?.media || baseTweet?.extended_entities?.media);
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) {
@@ -159,11 +244,35 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
const getFileExt = (url) => new URL(url).pathname.split(".", 2)[1];
const proxyMedia = (u, filename) => createStream({
const proxyMedia = (url, filename) => createStream({
service: "twitter",
type: "proxy",
u, filename,
})
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:
@@ -172,21 +281,37 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
error: "fetch.empty"
}
case 1:
if (media[0].type === "photo") {
const mediaItem = media[0];
if (mediaItem.type === "photo") {
return {
type: "proxy",
isPhoto: true,
filename: `twitter_${id}.${getFileExt(media[0].media_url_https)}`,
urls: `${media[0].media_url_https}?name=4096x4096`
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: needsFixing(media[0]) ? "remux" : "proxy",
urls: bestQuality(media[0].video_info.variants),
type: subtitles || needsFixing(mediaItem) ? "remux" : "proxy",
urls: bestQuality(mediaItem.video_info.variants),
filename: `twitter_${id}.mp4`,
audioFilename: `twitter_${id}_audio`,
isGif: media[0].type === "animated_gif"
isGif: mediaItem.type === "animated_gif",
subtitles,
fileMetadata,
}
default:
const proxyThumb = (url, i) =>
@@ -208,7 +333,7 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
let url = bestQuality(content.video_info.variants);
const shouldRenderGif = content.type === "animated_gif" && toGif;
const videoFilename = `twitter_${id}_${i + 1}.mp4`;
const videoFilename = `twitter_${id}_${i + 1}.${shouldRenderGif ? "gif" : "mp4"}`;
let type = "video";
if (shouldRenderGif) type = "gif";
@@ -217,7 +342,7 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
url = createStream({
service: "twitter",
type: shouldRenderGif ? "gif" : "remux",
u: url,
url,
filename: videoFilename,
})
} else if (alwaysProxy) {

View File

@@ -1,7 +1,7 @@
import HLS from "hls-parser";
import { env } from "../../config.js";
import { cleanString, merge } from '../../misc/utils.js';
import { merge } from '../../misc/utils.js';
import { getCookie } from "../cookie/manager.js";
const resolutionMatch = {
"3840": 2160,
@@ -16,7 +16,44 @@ const resolutionMatch = {
"426": 240
}
const requestApiInfo = (videoId, password) => {
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}`
}
@@ -25,10 +62,8 @@ const requestApiInfo = (videoId, password) => {
`https://api.vimeo.com/videos/${videoId}`,
{
headers: {
Accept: 'application/vnd.vimeo.*+json; version=3.4.2',
'User-Agent': 'Vimeo/10.19.0 (com.vimeo; build:101900.57.0; iOS 17.5.1) Alamofire/5.9.0 VimeoNetworking/5.0.0',
Authorization: 'Basic MTMxNzViY2Y0NDE0YTQ5YzhjZTc0YmU0NjVjNDQxYzNkYWVjOWRlOTpHKzRvMmgzVUh4UkxjdU5FRW80cDNDbDhDWGR5dVJLNUJZZ055dHBHTTB4V1VzaG41bEx1a2hiN0NWYWNUcldSSW53dzRUdFRYZlJEZmFoTTArOTBUZkJHS3R4V2llYU04Qnl1bERSWWxUdXRidjNqR2J4SHFpVmtFSUcyRktuQw==',
'Accept-Language': 'en'
...genericHeaders,
Authorization: `Bearer ${bearerToken}`,
}
}
)
@@ -41,7 +76,7 @@ const compareQuality = (rendition, requestedQuality) => {
return Math.abs(quality - requestedQuality);
}
const getDirectLink = (data, quality) => {
const getDirectLink = async (data, quality, subtitleLang) => {
if (!data.files) return;
const match = data.files
@@ -57,8 +92,23 @@ const getDirectLink = (data, quality) => {
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,
@@ -122,7 +172,7 @@ const getHLS = async (configURL, obj) => {
return {
urls,
isM3U8: true,
isHLS: true,
filenameAttributes: {
resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,
qualityLabel: `${resolutionMatch[bestQuality.resolution.width]}p`,
@@ -137,14 +187,33 @@ export default async function(obj) {
if (quality < 240) quality = 240;
if (!quality || obj.isAudioOnly) quality = 9000;
const info = await requestApiInfo(obj.id, obj.password);
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 = getDirectLink(info, quality);
if (!response) response = await getDirectLink(info, quality, obj.subtitleLang);
if (!response) response = { error: "fetch.empty" };
if (response.error) {
@@ -152,10 +221,14 @@ export default async function(obj) {
}
const fileMetadata = {
title: cleanString(info.name),
artist: cleanString(info.user.name),
title: info.name,
artist: info.user.name,
};
if (response.subtitles) {
fileMetadata.sublanguage = obj.subtitleLang;
}
return merge(
{
fileMetadata,

View File

@@ -1,15 +0,0 @@
export default async function(obj) {
let post = await fetch(`https://archive.vine.co/posts/${obj.id}.json`)
.then(r => r.json())
.catch(() => {});
if (!post) return { error: "fetch.empty" };
if (post.videoUrl) return {
urls: post.videoUrl.replace("http://", "https://"),
filename: `vine_${obj.id}.mp4`,
audioFilename: `vine_${obj.id}_audio`
}
return { error: "fetch.empty" }
}

View File

@@ -1,63 +1,152 @@
import { cleanString } from "../../misc/utils.js";
import { genericUserAgent, env } from "../../config.js";
import { env } from "../../config.js";
const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240"];
const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240", "144"];
export default async function(o) {
let html, url, quality = o.quality === "max" ? 2160 : o.quality;
const oauthUrl = "https://oauth.vk.com/oauth/get_anonym_token";
const apiUrl = "https://api.vk.com/method";
html = await fetch(`https://vk.com/video${o.userId}_${o.videoId}`, {
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": genericUserAgent
"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 => r.arrayBuffer())
.catch(() => {});
.then(r => {
if (r.status === 200) {
return r.json();
}
});
if (!html) return { error: "fetch.fail" };
return video;
}
// decode cyrillic from windows-1251 because vk still uses apis from prehistoric times
let decoder = new TextDecoder('windows-1251');
html = decoder.decode(html);
export default async function ({ ownerId, videoId, accessKey, quality, subtitleLang }) {
const token = await getToken();
if (!token) return { error: "fetch.fail" };
if (!html.includes(`{"lang":`)) return { error: "fetch.empty" };
const videoGet = await getVideo(ownerId, videoId, accessKey);
let js = JSON.parse('{"lang":' + html.split(`{"lang":`)[1].split(']);')[0]);
if (Number(js.mvData.is_active_live) !== 0) {
return { error: "content.video.live" };
if (!videoGet || !videoGet.response || videoGet.response.items.length !== 1) {
return { error: "fetch.empty" };
}
if (js.mvData.duration > env.durationLimit) {
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" };
}
for (let i in resolutions) {
if (js.player.params[0][`url${resolutions[i]}`]) {
quality = resolutions[i];
const userQuality = quality === "max" ? resolutions[0] : quality;
let pickedQuality;
for (const resolution of resolutions) {
if (video.files[`mp4_${resolution}`] && +resolution <= +userQuality) {
pickedQuality = resolution;
break
}
}
if (Number(quality) > Number(o.quality)) quality = o.quality;
url = js.player.params[0][`url${quality}`];
const url = video.files[`mp4_${pickedQuality}`];
let fileMetadata = {
title: cleanString(js.player.params[0].md_title.trim()),
author: cleanString(js.player.params[0].md_author.trim()),
if (!url) return { error: "fetch.fail" };
const fileMetadata = {
title: video.title.trim(),
}
if (url) return {
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: `${o.userId}_${o.videoId}`,
id: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`,
title: fileMetadata.title,
author: fileMetadata.author,
resolution: `${quality}p`,
qualityLabel: `${quality}p`,
resolution: `${pickedQuality}p`,
qualityLabel: `${pickedQuality}p`,
extension: "mp4"
}
}
return { error: "fetch.empty" }
}

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

@@ -1,16 +1,17 @@
import { fetch } from "undici";
import HLS from "hls-parser";
import { fetch } from "undici";
import { Innertube, Session } from "youtubei.js";
import { env } from "../../config.js";
import { cleanString } from "../../misc/utils.js";
import { getCookie, updateCookieValues } from "../cookie/manager.js";
import { getCookie } from "../cookie/manager.js";
import { getYouTubeSession } from "../helpers/youtube-session.js";
const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
let innertube, lastRefreshedAt;
const codecMatch = {
const codecList = {
h264: {
videoCodec: "avc1",
audioCodec: "mp4a",
@@ -18,8 +19,8 @@ const codecMatch = {
},
av1: {
videoCodec: "av01",
audioCodec: "mp4a",
container: "mp4"
audioCodec: "opus",
container: "webm"
},
vp9: {
videoCodec: "vp9",
@@ -28,117 +29,220 @@ const codecMatch = {
}
}
const transformSessionData = (cookie) => {
if (!cookie)
return;
const values = { ...cookie.values() };
const REQUIRED_VALUES = [ 'access_token', 'refresh_token' ];
if (REQUIRED_VALUES.some(x => typeof values[x] !== 'string')) {
return;
const hlsCodecList = {
h264: {
videoCodec: "avc1",
audioCodec: "mp4a",
container: "mp4"
},
vp9: {
videoCodec: "vp09",
audioCodec: "mp4a",
container: "webm"
}
if (values.expires) {
values.expiry_date = values.expires;
delete values.expires;
} else if (!values.expiry_date) {
return;
}
return values;
}
const cloneInnertube = async (customFetch) => {
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
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.key,
innertube.session.api_key,
innertube.session.api_version,
innertube.session.account_index,
innertube.session.config_data,
innertube.session.player,
undefined,
cookie,
customFetch ?? innertube.session.http.fetch,
innertube.session.cache
innertube.session.cache,
sessionTokens?.potoken
);
const cookie = getCookie('youtube_oauth');
const oauthData = transformSessionData(cookie);
if (!session.logged_in && oauthData) {
await session.oauth.init(oauthData);
session.logged_in = true;
}
if (session.logged_in) {
if (session.oauth.shouldRefreshToken()) {
await session.oauth.refreshAccessToken();
}
const cookieValues = cookie.values();
const oldExpiry = new Date(cookieValues.expiry_date);
const newExpiry = new Date(session.oauth.oauth2_tokens.expiry_date);
if (oldExpiry.getTime() !== newExpiry.getTime()) {
updateCookieValues(cookie, {
...session.oauth.client_id,
...session.oauth.oauth2_tokens,
expiry_date: newExpiry.toISOString()
});
}
}
const yt = new Innertube(session);
return yt;
}
export default async function(o) {
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) => fetch(input, {
...init,
dispatcher: o.dispatcher
})
}),
useSession
);
} catch(e) {
if (e.message?.endsWith("decipher algorithm")) {
} 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;
}
const quality = o.quality === "max" ? "9000" : o.quality;
let info, isDubbed,
format = o.format || "h264";
function qual(i) {
if (!i.quality_label) {
return;
}
return i.quality_label.split('p')[0].split('s')[0]
}
let info;
try {
info = await yt.getBasicInfo(o.id, yt.session.logged_in ? 'ANDROID' : 'IOS');
} catch(e) {
if (e?.info?.reason === "This video is private") {
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" };
} else if (e?.message === "This video is unavailable") {
return { error: "content.video.unavailable" };
} else {
return { error: "fetch.fail" };
}
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" };
@@ -146,19 +250,20 @@ export default async function(o) {
const playability = info.playability_status;
const basicInfo = info.basic_info;
if (playability.status === "LOGIN_REQUIRED") {
switch (playability.status) {
case "LOGIN_REQUIRED":
if (playability.reason.endsWith("bot")) {
return { error: "youtube.login" }
}
if (playability.reason.endsWith("age")) {
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;
if (playability.status === "UNPLAYABLE") {
case "UNPLAYABLE":
if (playability?.reason?.endsWith("request limit.")) {
return { error: "fetch.rate" }
}
@@ -168,15 +273,24 @@ export default async function(o) {
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) {
@@ -186,126 +300,306 @@ export default async function(o) {
}
}
const filterByCodec = (formats) =>
formats
.filter(e =>
e.mime_type.includes(codecMatch[format].videoCodec)
|| e.mime_type.includes(codecMatch[format].audioCodec)
)
.sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
let adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats);
if (adaptive_formats.length === 0 && format === "vp9") {
format = "h264"
adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats)
const normalizeQuality = res => {
const shortestSide = Math.min(res.height, res.width);
return videoQualities.find(qual => qual >= shortestSide);
}
let bestQuality;
let video, audio, subtitles, dubbedLanguage,
codec = o.codec || "h264", itag = o.itag;
const bestVideo = adaptive_formats.find(i => i.has_video && i.content_length);
const hasAudio = adaptive_formats.find(i => i.has_audio && i.content_length);
if (bestVideo) bestQuality = qual(bestVideo);
if ((!bestQuality && !o.isAudioOnly) || !hasAudio)
return { error: "youtube.codec" };
if (basicInfo.duration > env.durationLimit)
return { error: "content.too_long" };
const checkBestAudio = (i) => (i.has_audio && !i.has_video);
let audio = adaptive_formats.find(i =>
checkBestAudio(i) && i.is_original
if (useHLS) {
const variants = await getHlsVariants(
info.streaming_data.hls_manifest_url,
o.dispatcher
);
if (o.dubLang) {
let dubbedAudio = adaptive_formats.find(i =>
checkBestAudio(i)
&& i.language === o.dubLang
&& i.audio_track
)
if (variants?.error) return variants;
if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) {
audio = dubbedAudio;
isDubbed = true;
}
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 = adaptive_formats.find(i => checkBestAudio(i));
audio = selected.audio.find(i => i.name.endsWith("original"));
}
let fileMetadata = {
title: cleanString(basicInfo.title.trim()),
artist: cleanString(basicInfo.author.replace("- Topic", "").trim()),
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")) {
let descItems = basicInfo.short_description.split("\n\n");
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()
fileMetadata.date = descItems[4].replace("Released on: ", '').trim();
}
}
}
let filenameAttributes = {
if (subtitles) {
fileMetadata.sublanguage = subtitles.language;
}
const filenameAttributes = {
service: "youtube",
id: o.id,
title: fileMetadata.title,
author: fileMetadata.artist,
youtubeDubName: isDubbed ? o.dubLang : false
youtubeDubName: dubbedLanguage || false,
}
if (audio && o.isAudioOnly) return {
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 = 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: audio.decipher(yt.session.player),
filenameAttributes: filenameAttributes,
fileMetadata: fileMetadata,
bestAudio: format === "h264" ? "m4a" : "opus"
}
const matchingQuality = Number(quality) > Number(bestQuality) ? bestQuality : quality,
checkSingle = i =>
qual(i) === matchingQuality && i.mime_type.includes(codecMatch[format].videoCodec),
checkRender = i =>
qual(i) === matchingQuality && i.has_video && !i.has_audio;
let match, type, urls;
// prefer good premuxed videos if available
if (!o.isAudioOnly && !o.isAudioMuted && format === "h264" && bestVideo.fps <= 30) {
match = info.streaming_data.formats.find(checkSingle);
type = "proxy";
urls = match?.decipher(yt.session.player);
}
const video = adaptive_formats.find(checkRender);
if (!match && video && audio) {
match = video;
type = "merge";
urls = [
video.decipher(yt.session.player),
audio.decipher(yt.session.player)
]
}
if (match) {
filenameAttributes.qualityLabel = match.quality_label;
filenameAttributes.resolution = `${match.width}x${match.height}`;
filenameAttributes.extension = codecMatch[format].container;
filenameAttributes.youtubeFormat = format;
return {
type,
urls,
filenameAttributes,
fileMetadata
fileMetadata,
bestAudio,
isHLS: useHLS,
originalRequest,
cover,
cropCover: basicInfo.author.endsWith("- Topic"),
}
}
return { error: "fetch.fail" }
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 = video.decipher(innertube.session.player);
audio = 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,8 +1,9 @@
import psl from "psl";
import psl from "@imput/psl";
import { strict as assert } from "node:assert";
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) {
@@ -16,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;
@@ -42,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;
@@ -60,23 +61,23 @@ function aliasURL(url) {
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;
@@ -85,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) {
@@ -107,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) {
@@ -157,7 +200,7 @@ export function normalizeURL(url) {
);
}
export function extract(url) {
export function extract(url, enabledServices = env.enabledServices) {
if (!(url instanceof URL)) {
url = new URL(url);
}
@@ -168,7 +211,12 @@ export function extract(url) {
return { error: "link.invalid" };
}
if (!env.enabledServices.has(host)) {
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" };
}
@@ -194,3 +242,17 @@ export function extract(url) {
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;
}

View File

@@ -6,12 +6,19 @@ import { env } from "../config.js";
const toBase64URL = (b) => Buffer.from(b).toString("base64url");
const fromBase64URL = (b) => Buffer.from(b, "base64url").toString();
const makeHmac = (header, payload) =>
createHmac("sha256", env.jwtSecret)
.update(`${header}.${payload}`)
const makeHmac = (data) => {
return createHmac("sha256", env.jwtSecret)
.update(data)
.digest("base64url");
}
const generate = () => {
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({
@@ -21,10 +28,11 @@ const generate = () => {
const payload = toBase64URL(JSON.stringify({
jti: nanoid(8),
sub: getIPHash(ip),
exp,
}));
const signature = makeHmac(header, payload);
const signature = sign(header, payload);
return {
token: `${header}.${payload}.${signature}`,
@@ -32,7 +40,7 @@ const generate = () => {
};
}
const verify = (jwt) => {
const verify = (jwt, ip) => {
const [header, payload, signature] = jwt.split(".", 3);
const timestamp = Math.floor(new Date().getTime() / 1000);
@@ -40,17 +48,16 @@ const verify = (jwt) => {
return false;
}
const verifySignature = makeHmac(header, payload);
const verifySignature = sign(header, payload);
if (verifySignature !== signature) {
return false;
}
if (timestamp >= JSON.parse(fromBase64URL(payload)).exp) {
return false;
}
const data = JSON.parse(fromBase64URL(payload));
return true;
return getIPHash(ip) === data.sub
&& timestamp <= data.exp;
}
export default {

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,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

@@ -1,5 +1,6 @@
import HLS from "hls-parser";
import { createInternalStream } from "./manage.js";
import { request } from "undici";
function getURL(url) {
try {
@@ -16,16 +17,18 @@ function transformObject(streamInfo, hlsObject) {
let fullUrl;
if (getURL(hlsObject.uri)) {
fullUrl = 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;
}
@@ -53,8 +56,11 @@ function transformMediaPlaylist(streamInfo, hlsPlaylist) {
const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"];
export function isHlsRequest (req) {
return HLS_MIME_TYPES.includes(req.headers['content-type']);
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) {
@@ -69,3 +75,67 @@ export async function handleHlsPlaylist(streamInfo, req, res) {
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;
}

View File

@@ -1,13 +1,15 @@
import { request } from "undici";
import { Readable } from "node:stream";
import { closeRequest, getHeaders, pipe } from "./shared.js";
import { handleHlsPlaylist, isHlsRequest } from "./internal-hls.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;
let read = 0n, chunksSinceTransplant = 0;
while (read < size) {
if (streamInfo.controller.signal.aborted) {
throw new Error("controller aborted");
@@ -15,13 +17,24 @@ async function* readChunks(streamInfo, size) {
const chunk = await request(streamInfo.url, {
headers: {
...getHeaders('youtube'),
...getHeaders(streamInfo.service),
Range: `bytes=${read}-${read + CHUNK_SIZE}`
},
dispatcher: streamInfo.dispatcher,
signal: streamInfo.controller.signal
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']);
@@ -37,19 +50,30 @@ async function* readChunks(streamInfo, size) {
}
}
async function handleYoutubeStream(streamInfo, res) {
async function handleChunkedStream(streamInfo, res) {
const { signal } = streamInfo.controller;
const cleanup = () => (res.end(), closeRequest(streamInfo.controller));
try {
const req = await fetch(streamInfo.url, {
headers: getHeaders('youtube'),
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) {
@@ -83,7 +107,7 @@ async function handleGenericStream(streamInfo, res) {
const cleanup = () => res.end();
try {
const req = await request(streamInfo.url, {
const fileResponse = await request(streamInfo.url, {
headers: {
...Object.fromEntries(streamInfo.headers),
host: undefined
@@ -93,19 +117,25 @@ async function handleGenericStream(streamInfo, res) {
maxRedirections: 16
});
res.status(req.statusCode);
req.body.on('error', () => {});
res.status(fileResponse.statusCode);
fileResponse.body.on('error', () => {});
for (const [ name, value ] of Object.entries(req.headers))
res.setHeader(name, value)
const isHls = isHlsResponse(fileResponse, streamInfo);
if (req.statusCode < 200 || req.statusCode > 299)
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 (isHlsRequest(req)) {
await handleHlsPlaylist(streamInfo, req, res);
if (isHls) {
await handleHlsPlaylist(streamInfo, fileResponse, res);
} else {
pipe(req.body, res, cleanup);
pipe(fileResponse.body, res, cleanup);
}
} catch {
closeRequest(streamInfo.controller);
@@ -114,9 +144,50 @@ async function handleGenericStream(streamInfo, res) {
}
export function internalStream(streamInfo, res) {
if (streamInfo.service === 'youtube') {
return handleYoutubeStream(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 {}
}

View File

@@ -1,4 +1,4 @@
import NodeCache from "node-cache";
import Store from "../store/store.js";
import { nanoid } from "nanoid";
import { randomBytes } from "crypto";
@@ -7,34 +7,27 @@ import { setMaxListeners } from "node:events";
import { env } from "../config.js";
import { closeRequest } from "./shared.js";
import { decryptStream, encryptStream, generateHmac } from "../misc/crypto.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 NodeCache({
stdTTL: env.streamLifespan,
checkperiod: 10,
deleteOnExpire: true
})
streamCache.on("expired", (key) => {
streamCache.del(key);
})
const streamCache = new Store('streams');
const internalStreamCache = new Map();
const hmacSalt = randomBytes(64).toString('hex');
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 = generateHmac(`${streamID},${exp},${iv},${secret}`, hmacSalt),
hmac = hashHmac(`${streamID},${exp},${iv},${secret}`, 'stream').toString('base64url'),
streamData = {
exp: exp,
type: obj.type,
urls: obj.u,
urls: obj.url,
service: obj.service,
filename: obj.filename,
@@ -46,12 +39,22 @@ export function createStream(obj) {
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)
)
encryptStream(streamData, iv, secret),
env.streamLifespan
);
let streamLink = new URL('/tunnel', env.apiURL);
@@ -70,14 +73,73 @@ export function createStream(obj) {
return streamLink.toString();
}
export function getInternalStream(id) {
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 createInternalStream(url, obj = {}) {
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;
let dispatcher = obj.dispatcher;
if (obj.requestIP) {
dispatcher = freebind?.dispatcherFromIP(obj.requestIP, { strict: false })
}
@@ -95,15 +157,20 @@ export function createInternalStream(url, obj = {}) {
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: obj.service,
service,
headers,
controller,
dispatcher
dispatcher,
isHLS: obj.isHLS,
transplant: obj.transplant
});
let streamLink = new URL('/itunnel', `http://127.0.0.1:${env.apiPort}`);
let streamLink = new URL('/itunnel', `http://127.0.0.1:${env.tunnelPort}`);
streamLink.searchParams.set('id', streamID);
const cleanup = () => {
@@ -116,23 +183,86 @@ export function createInternalStream(url, obj = {}) {
return streamLink.toString();
}
export function destroyInternalStream(url) {
function getInternalTunnelId(url) {
url = new URL(url);
if (url.hostname !== '127.0.0.1') {
return;
}
const id = url.searchParams.get('id');
return url.searchParams.get('id');
}
export function destroyInternalStream(url) {
const id = getInternalTunnelId(url);
if (internalStreamCache.has(id)) {
closeRequest(getInternalStream(id)?.controller);
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)) {
@@ -143,13 +273,21 @@ function wrapStream(streamInfo) {
}
} else throw 'invalid urls';
if (streamInfo.subtitles) {
streamInfo.subtitles = createInternalStream(
streamInfo.subtitles,
streamInfo,
/*isSubtitles=*/true
);
}
return streamInfo;
}
export function verifyStream(id, hmac, exp, secret, iv) {
export async function verifyStream(id, hmac, exp, secret, iv) {
try {
const ghmac = generateHmac(`${id},${exp},${iv},${secret}`, hmacSalt);
const cache = streamCache.get(id.toString());
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 };

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();
}
}

View File

@@ -1,4 +1,7 @@
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
@@ -13,6 +16,12 @@ const serviceHeaders = {
origin: 'https://www.youtube.com',
referer: 'https://www.youtube.com',
DNT: '?1'
},
vk: {
'user-agent': vkClientAgent
},
tiktok: {
referer: 'https://www.tiktok.com/',
}
}
@@ -43,3 +52,40 @@ export function pipe(from, to, 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;
}

View File

@@ -1,4 +1,5 @@
import stream from "./types.js";
import proxy from "./proxy.js";
import ffmpeg from "./ffmpeg.js";
import { closeResponse } from "./shared.js";
import { internalStream } from "./internal.js";
@@ -7,23 +8,21 @@ export default async function(res, streamInfo) {
try {
switch (streamInfo.type) {
case "proxy":
return await stream.proxy(streamInfo, res);
return await proxy(streamInfo, res);
case "internal":
return internalStream(streamInfo, res);
return await internalStream(streamInfo.data, res);
case "merge":
return stream.merge(streamInfo, res);
case "remux":
case "mute":
return stream.remux(streamInfo, res);
return await ffmpeg.remux(streamInfo, res);
case "audio":
return stream.convertAudio(streamInfo, res);
return await ffmpeg.convertAudio(streamInfo, res);
case "gif":
return stream.convertGif(streamInfo, res);
return await ffmpeg.convertGif(streamInfo, res);
}
closeResponse(res);

View File

@@ -1,311 +0,0 @@
import { request } from "undici";
import ffmpeg from "ffmpeg-static";
import { spawn } from "child_process";
import { create as contentDisposition } from "content-disposition-header";
import { env } from "../config.js";
import { metadataManager } from "../misc/utils.js";
import { destroyInternalStream } from "./manage.js";
import { hlsExceptions } from "../processing/service-config.js";
import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js";
const ffmpegArgs = {
webm: ["-c:v", "copy", "-c:a", "copy"],
mp4: ["-c:v", "copy", "-c:a", "copy", "-movflags", "faststart+frag_keyframe+empty_moov"],
m4a: ["-movflags", "frag_keyframe+empty_moov"],
gif: ["-vf", "scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", "-loop", "0"]
}
const toRawHeaders = (headers) => {
return Object.entries(headers)
.map(([key, value]) => `${key}: ${value}\r\n`)
.join('');
}
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 proxy = async (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
});
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();
}
}
const merge = (streamInfo, res) => {
let process;
const shutdown = () => (
killProcess(process),
closeResponse(res),
streamInfo.urls.map(destroyInternalStream)
);
const headers = getHeaders(streamInfo.service);
const rawHeaders = toRawHeaders(headers);
try {
if (streamInfo.urls.length !== 2) return shutdown();
const format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1];
let args = [
'-loglevel', '-8',
'-headers', rawHeaders,
'-i', streamInfo.urls[0],
'-headers', rawHeaders,
'-i', streamInfo.urls[1],
'-map', '0:v',
'-map', '1:a',
]
args = args.concat(ffmpegArgs[format]);
if (hlsExceptions.includes(streamInfo.service)) {
args.push('-bsf:a', 'aac_adtstoasc')
}
if (streamInfo.metadata) {
args = args.concat(metadataManager(streamInfo.metadata))
}
args.push('-f', format, 'pipe:3');
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));
pipe(muxOutput, res, shutdown);
process.on('close', shutdown);
res.on('finish', shutdown);
} catch {
shutdown();
}
}
const remux = (streamInfo, res) => {
let process;
const shutdown = () => (
killProcess(process),
closeResponse(res),
destroyInternalStream(streamInfo.urls)
);
try {
let args = [
'-loglevel', '-8',
'-headers', toRawHeaders(getHeaders(streamInfo.service)),
]
if (streamInfo.service === "twitter") {
args.push('-seekable', '0')
}
args.push(
'-i', streamInfo.urls,
'-c:v', 'copy',
)
if (streamInfo.type === "mute") {
args.push('-an');
}
if (hlsExceptions.includes(streamInfo.service)) {
if (streamInfo.type !== "mute") {
args.push('-c:a', 'aac')
}
args.push('-bsf:a', 'aac_adtstoasc');
}
let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1];
if (format === "mp4") {
args.push('-movflags', 'faststart+frag_keyframe+empty_moov')
}
args.push('-f', format, 'pipe:3');
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));
pipe(muxOutput, res, shutdown);
process.on('close', shutdown);
res.on('finish', shutdown);
} catch {
shutdown();
}
}
const convertAudio = (streamInfo, res) => {
let process;
const shutdown = () => (
killProcess(process),
closeResponse(res),
destroyInternalStream(streamInfo.urls)
);
try {
let args = [
'-loglevel', '-8',
'-headers', toRawHeaders(getHeaders(streamInfo.service)),
]
if (streamInfo.service === "twitter") {
args.push('-seekable', '0');
}
args.push(
'-i', streamInfo.urls,
'-vn'
)
if (streamInfo.audioCopy) {
args.push("-c:a", "copy")
} else {
args.push("-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 (ffmpegArgs[streamInfo.audioFormat]) {
args = args.concat(ffmpegArgs[streamInfo.audioFormat])
}
if (streamInfo.metadata) {
args = args.concat(metadataManager(streamInfo.metadata))
}
args.push('-f', streamInfo.audioFormat === "m4a" ? "ipod" : streamInfo.audioFormat, 'pipe:3');
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));
pipe(muxOutput, res, shutdown);
res.on('finish', shutdown);
} catch {
shutdown();
}
}
const convertGif = (streamInfo, res) => {
let process;
const shutdown = () => (killProcess(process), closeResponse(res));
try {
let args = [
'-loglevel', '-8'
]
if (streamInfo.service === "twitter") {
args.push('-seekable', '0')
}
args.push('-i', streamInfo.urls);
args = args.concat(ffmpegArgs.gif);
args.push('-f', "gif", 'pipe:3');
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.split('.')[0] + ".gif"));
pipe(muxOutput, res, shutdown);
process.on('close', shutdown);
res.on('finish', shutdown);
} catch {
shutdown();
}
}
export default {
proxy,
merge,
remux,
convertAudio,
convertGif,
}

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))}`)

View File

@@ -1,38 +0,0 @@
import { Innertube } from 'youtubei.js';
import { Red } from '../misc/console-text.js'
const bail = (...msg) => {
console.error(...msg);
throw new Error(msg);
};
const tube = await Innertube.create();
tube.session.once(
'auth-pending',
({ verification_url, user_code }) => {
console.log(`${Red('[!]')} The token generated by this script is sensitive and you should not share it with anyone!`);
console.log(` By using this token, you are risking your Google account getting terminated.`);
console.log(` You should ${Red('NOT')} use your personal account!`);
console.log();
console.log(`Open ${verification_url} in a browser and enter ${user_code} when asked for the code.`);
}
);
tube.session.once('auth-error', (err) => bail('An error occurred:', err));
tube.session.once('auth', ({ credentials }) => {
if (!credentials.access_token) {
bail('something went wrong');
}
console.log(
'add this cookie to the youtube_oauth array in your cookies file:',
JSON.stringify(
Object.entries(credentials)
.map(([k, v]) => `${k}=${v instanceof Date ? v.toISOString() : v}`)
.join('; ')
)
);
});
await tube.session.signIn();

View File

@@ -1,105 +0,0 @@
import { existsSync, unlinkSync, appendFileSync } from "fs";
import { createInterface } from "readline";
import { Cyan, Bright } from "./misc/console-text.js";
import { loadJSON } from "./misc/load-from-fs.js";
import { execSync } from "child_process";
const { version } = loadJSON("./package.json");
let envPath = './.env';
let q = `${Cyan('?')} \x1b[1m`;
let ob = {};
let rl = createInterface({ input: process.stdin, output: process.stdout });
let final = () => {
if (existsSync(envPath)) unlinkSync(envPath);
for (let i in ob) {
appendFileSync(envPath, `${i}=${ob[i]}\n`)
}
console.log(Bright("\nAwesome! I've created a fresh .env file for you."));
console.log(`${Bright("Now I'll run")} ${Cyan("npm install")} ${Bright("to install all dependencies. It shouldn't take long.\n\n")}`);
execSync('npm install', { stdio: [0, 1, 2] });
console.log(`\n\n${Cyan("All done!\n")}`);
console.log(Bright("You can re-run this script at any time to update the configuration."));
console.log(Bright("\nYou're now ready to start cobalt. Simply run ") + Cyan("npm start") + Bright('!\nHave fun :)'));
rl.close()
}
console.log(
`${Cyan(`Hey, this is cobalt v.${version}!`)}\n${Bright("Let's start by creating a new ")}${Cyan(".env")}${Bright(" file. You can always change it later.")}`
)
function setup() {
console.log(Bright("\nWhat kind of server will this instance be?\nOptions: api, web."));
rl.question(q, r1 => {
switch (r1.toLowerCase()) {
case 'api':
console.log(Bright("\nCool! What's the domain this API instance will be running on? (localhost)\nExample: api.cobalt.tools"));
rl.question(q, apiURL => {
ob.API_URL = `http://localhost:9000/`;
ob.API_PORT = 9000;
if (apiURL && apiURL !== "localhost") ob.API_URL = `https://${apiURL.toLowerCase()}/`;
console.log(Bright("\nGreat! Now, what port will it be running on? (9000)"));
rl.question(q, apiPort => {
if (apiPort) ob.API_PORT = apiPort;
if (apiPort && (apiURL === "localhost" || !apiURL)) ob.API_URL = `http://localhost:${apiPort}/`;
console.log(Bright("\nWhat will your instance's name be? Usually it's something like eu-nl aka region-country. (local)"));
rl.question(q, apiName => {
ob.API_NAME = apiName.toLowerCase();
if (!apiName || apiName === "local") ob.API_NAME = "local";
console.log(Bright("\nOne last thing: would you like to enable CORS? It allows other websites and extensions to use your instance's API.\ny/n (n)"));
rl.question(q, apiCors => {
let answCors = apiCors.toLowerCase().trim();
if (answCors !== "y" && answCors !== "yes") ob.CORS_WILDCARD = '0'
final()
})
})
});
})
break;
case 'web':
console.log(Bright("\nAwesome! What's the domain this web app instance will be running on? (localhost)\nExample: cobalt.tools"));
rl.question(q, webURL => {
ob.WEB_URL = `http://localhost:9001/`;
ob.WEB_PORT = 9001;
if (webURL && webURL !== "localhost") ob.WEB_URL = `https://${webURL.toLowerCase()}/`;
console.log(
Bright("\nGreat! Now, what port will it be running on? (9001)")
)
rl.question(q, webPort => {
if (webPort) ob.WEB_PORT = webPort;
if (webPort && (webURL === "localhost" || !webURL)) ob.WEB_URL = `http://localhost:${webPort}/`;
console.log(
Bright("\nOne last thing: what default API domain should be used? (api.cobalt.tools)\nIf it's hosted locally, make sure to include the port:") + Cyan(" localhost:9000")
);
rl.question(q, apiURL => {
ob.API_URL = `https://${apiURL.toLowerCase()}/`;
if (apiURL.includes(':')) ob.API_URL = `http://${apiURL.toLowerCase()}/`;
if (!apiURL) ob.API_URL = "https://api.cobalt.tools/";
final()
})
});
});
break;
default:
console.log(Bright("\nThis is not an option. Try again."));
setup()
}
})
}
setup()

View File

@@ -1,82 +0,0 @@
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 { randomizeCiphers } from "../misc/randomize-ciphers.js";
import { services } from "../processing/service-config.js";
const tests = loadJSON('./src/util/tests.json');
// services that are known to frequently fail due to external
// factors (e.g. rate limiting)
const finnicky = new Set(['bilibili', 'instagram', 'facebook', 'youtube'])
const action = process.argv[2];
switch (action) {
case "get-services":
const fromConfig = Object.keys(services);
const missingTests = fromConfig.filter(
service => !tests[service] || tests[service].length === 0
);
if (missingTests.length) {
console.error('services have no tests:', missingTests);
console.log('[]');
process.exitCode = 1;
break;
}
console.log(JSON.stringify(fromConfig));
break;
case "run-tests-for":
const service = process.argv[3];
let failed = false;
if (!tests[service]) {
console.error('no such service:', service);
}
env.streamLifespan = 10000;
env.apiURL = 'http://x';
randomizeCiphers();
for (const test of tests[service]) {
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) {
failed = !canFail;
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);
});
}
}
process.exitCode = Number(failed);
break;
default:
console.error('invalid action:', action);
process.exitCode = 1;
}

View File

@@ -1,84 +1,136 @@
import "dotenv/config";
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";
import { extract } from "../processing/url.js";
import match from "../processing/match.js";
import { loadJSON } from "../misc/load-from-fs.js";
import { normalizeRequest } from "../processing/request.js";
import { env } from "../config.js";
env.apiURL = 'http://localhost:9000'
let tests = loadJSON('./src/util/tests.json');
const getTestPath = service => path.join('./src/util/tests/', `./${service}.json`);
const getTests = (service) => loadJSON(getTestPath(service));
let noTest = [];
let failed = [];
let success = 0;
// 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']
);
function addToFail(service, testName, url, status, response) {
failed.push({
service: service,
name: testName,
url: url,
status: status,
response: response
})
}
for (let i in services) {
if (tests[i]) {
console.log(`\nRunning tests for ${i}...\n`)
for (let k = 0; k < tests[i].length; k++) {
let test = tests[i][k];
const runTestsFor = async (service) => {
const tests = getTests(service);
let softFails = 0, fails = 0;
console.log(`Running test ${k+1}: ${test.name}`);
console.log('params:');
let params = {...{url: test.url}, ...test.params};
console.log(params);
let chck = await normalizeRequest(params);
if (chck.success) {
chck = chck.data;
const parsed = extract(chck.url);
if (parsed === null) {
throw `Invalid URL: ${chck.url}`
if (!tests) {
throw "no such service: " + service;
}
let j = await match({
host: parsed.host,
patternMatch: parsed.patternMatch,
params: chck,
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);
});
console.log('\nReceived:');
console.log(j)
if (j.status === test.expected.code && j.body.status === test.expected.status) {
console.log("\n✅ Success.\n");
success++
} else {
console.log(`\n❌ Fail. Expected: ${test.expected.code} & ${test.expected.status}, received: ${j.status} & ${j.body.status}\n`);
addToFail(i, test.name, test.url, j.body.status, j)
}
} else {
console.log("\n❌ couldn't validate the request JSON.\n");
addToFail(i, test.name, test.url, "unknown", {})
}
}
console.log("\n\n")
} else {
console.warn(`No tests found for ${i}.`);
noTest.push(i)
}
return { fails, softFails };
}
console.log(`${success} tests succeeded.`);
console.log(`${failed.length} tests failed.`);
console.log(`${noTest.length} services weren't tested.`);
if (failed.length > 0) {
console.log(`\nFailed tests:`);
console.log(failed)
const printHeader = (service, padLen) => {
const padding = padLen - service.length;
service = service.padEnd(1 + service.length + padding, ' ');
console.log(service + '='.repeat(50));
}
if (noTest.length > 0) {
console.log(`\nMissing tests:`);
console.log(noTest)
// 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}`);
}
}

File diff suppressed because it is too large Load Diff

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"
}
}
]

View File

@@ -0,0 +1,230 @@
[
{
"name": "regular video",
"url": "https://twitter.com/X/status/1697304622749086011",
"params": {
"audioFormat": "mp3"
},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "video with mobile web mediaviewer",
"url": "https://twitter.com/X/status/1697304622749086011/mediaViewer?currentTweet=1697304622749086011&currentTweetUser=X&currentTweet=1697304622749086011&currentTweetUser=X",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "embedded twitter video",
"url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20",
"params": {
"audioFormat": "mp3"
},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "mixed media (image + gif)",
"url": "https://twitter.com/sky_mj26/status/1807756010712428565",
"params": {
"audioFormat": "mp3"
},
"expected": {
"code": 200,
"status": "picker"
}
},
{
"name": "picker: mixed media (video + image)",
"url": "https://x.com/PopCrave/status/1682176754792955905",
"params": {
"audioFormat": "mp3"
},
"expected": {
"code": 200,
"status": "picker"
}
},
{
"name": "audio from embedded twitter video (mp3, isAudioOnly)",
"url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20",
"params": {
"downloadMode": "audio",
"audioFormat": "mp3"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "audio from embedded twitter video (best, isAudioOnly)",
"url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20",
"params": {
"downloadMode": "audio",
"audioFormat": "best"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "audio from embedded twitter video (ogg, isAudioOnly, isAudioMuted)",
"url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20",
"params": {
"downloadMode": "audio",
"audioFormat": "best"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "muted embedded twitter video",
"url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20",
"params": {
"downloadMode": "mute",
"audioFormat": "mp3"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "retweeted video",
"url": "https://twitter.com/schlizzawg/status/1869017025055793405",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "age restricted video",
"url": "https://x.com/XSpaces/status/1526955853743546372",
"params": {},
"canFail": true,
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "twitter voice + x.com link",
"url": "https://x.com/eggsaladscreams/status/1693089534886506756?s=46",
"params": {},
"canFail": true,
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "vxtwitter link",
"url": "https://vxtwitter.com/dustbin_nie/status/1624596567188717568?s=20",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "post with 1 image",
"url": "https://x.com/PopCrave/status/1815960083475423235",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "post with 4 images",
"url": "https://x.com/PopCrave/status/1877880433242771717",
"params": {},
"expected": {
"code": 200,
"status": "picker"
}
},
{
"name": "retweeted video, isAudioOnly",
"url": "https://twitter.com/hugekiwinuts/status/1618671150829309953?s=46&t=gItGzgwGQQJJaJrO6qc1Pg",
"params": {
"downloadMode": "mute",
"audioFormat": "mp3"
},
"canFail": true,
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "gif",
"url": "https://x.com/thelastromances/status/1897839691212202479",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "inexistent post",
"url": "https://twitter.com/test/status/9487653",
"params": {
"audioFormat": "best"
},
"expected": {
"code": 400,
"status": "error"
}
},
{
"name": "post with no media content",
"url": "https://twitter.com/elonmusk/status/1604617643973124097?s=20",
"params": {
"audioFormat": "best"
},
"expected": {
"code": 400,
"status": "error"
}
},
{
"name": "bookmarked video",
"url": "https://twitter.com/i/bookmarks?post_id=1828099210220294314",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "bookmarked photo",
"url": "https://twitter.com/i/bookmarks?post_id=1887450602164396149",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "video in an ad card",
"url": "https://x.com/igorbrigadir/status/1611399816487084033?s=46",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
}
]

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