├── .deepsource.toml ├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug-main-instance.yml │ ├── bug-report.yml │ ├── config.yml │ ├── feature-request.yml │ ├── hosting-help.yml │ └── service-request.yml ├── test.sh └── workflows │ ├── codeql.yml │ ├── docker-develop.yml │ ├── docker-staging.yml │ ├── docker.yml │ ├── fast-forward.yml │ ├── test-services.yml │ └── test.yml ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── api ├── LICENSE ├── README.md ├── package.json └── src │ ├── cobalt.js │ ├── config.js │ ├── core │ ├── api.js │ ├── env.js │ └── itunnel.js │ ├── misc │ ├── cluster.js │ ├── console-text.js │ ├── crypto.js │ ├── file-watcher.js │ ├── load-from-fs.js │ ├── randomize-ciphers.js │ ├── run-test.js │ └── utils.js │ ├── processing │ ├── cookie │ │ ├── cookie.js │ │ └── manager.js │ ├── create-filename.js │ ├── helpers │ │ └── youtube-session.js │ ├── match-action.js │ ├── match.js │ ├── request.js │ ├── schema.js │ ├── service-alias.js │ ├── service-config.js │ ├── service-patterns.js │ ├── services │ │ ├── bilibili.js │ │ ├── bluesky.js │ │ ├── dailymotion.js │ │ ├── facebook.js │ │ ├── instagram.js │ │ ├── loom.js │ │ ├── ok.js │ │ ├── pinterest.js │ │ ├── reddit.js │ │ ├── rutube.js │ │ ├── snapchat.js │ │ ├── soundcloud.js │ │ ├── streamable.js │ │ ├── tiktok.js │ │ ├── tumblr.js │ │ ├── twitch.js │ │ ├── twitter.js │ │ ├── vimeo.js │ │ ├── vk.js │ │ ├── xiaohongshu.js │ │ └── youtube.js │ └── url.js │ ├── security │ ├── api-keys.js │ ├── jwt.js │ ├── secrets.js │ └── turnstile.js │ ├── store │ ├── base-store.js │ ├── memory-store.js │ ├── redis-ratelimit.js │ ├── redis-store.js │ └── store.js │ ├── stream │ ├── internal-hls.js │ ├── internal.js │ ├── manage.js │ ├── shared.js │ ├── stream.js │ └── types.js │ └── util │ ├── generate-jwt-secret.js │ ├── test.js │ └── tests │ ├── bilibili.json │ ├── bsky.json │ ├── dailymotion.json │ ├── facebook.json │ ├── instagram.json │ ├── loom.json │ ├── ok.json │ ├── pinterest.json │ ├── reddit.json │ ├── rutube.json │ ├── snapchat.json │ ├── soundcloud.json │ ├── streamable.json │ ├── tiktok.json │ ├── tumblr.json │ ├── twitch.json │ ├── twitter.json │ ├── vimeo.json │ ├── vk.json │ ├── xiaohongshu.json │ └── youtube.json ├── docs ├── api-env-variables.md ├── api.md ├── examples │ ├── cookies.example.json │ └── docker-compose.example.yml ├── images │ └── protect-an-instance │ │ ├── add.png │ │ ├── created.png │ │ ├── domain.png │ │ ├── mode.png │ │ ├── name.png │ │ └── sidebar.png ├── protect-an-instance.md └── run-an-instance.md ├── package.json ├── packages ├── api-client │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc │ ├── LICENSE │ ├── package.json │ └── tsconfig.json └── version-info │ ├── index.d.ts │ ├── index.js │ └── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── web ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── changelogs ├── 10.0.md ├── 10.1.md ├── 10.3.md ├── 10.5.md ├── 11.0.md ├── 2.0.md ├── 2.2.5.md ├── 2.2.6.md ├── 2.2.8.md ├── 2.2.9.md ├── 2.2.md ├── 3.0.md ├── 3.1.md ├── 3.2.md ├── 3.4.md ├── 3.5.2.md ├── 3.5.4.md ├── 3.5.md ├── 3.6.3.md ├── 3.6.md ├── 3.7.md ├── 4.0.md ├── 4.1.md ├── 4.2.md ├── 4.3.2.md ├── 4.3.md ├── 4.4.md ├── 4.5.md ├── 4.6.md ├── 4.7.md ├── 4.8.md ├── 5.0.md ├── 5.1.md ├── 5.2.md ├── 5.3.md ├── 5.4.md ├── 6.0.md ├── 6.2.md ├── 7.0.md ├── 7.1.md ├── 7.11.md ├── 7.13.md ├── 7.14.md ├── 7.3.md ├── 7.4.md ├── 7.5.md ├── 7.6.md ├── 7.7.md ├── 7.8.md └── 7.9.md ├── eslint.config.js ├── i18n ├── en │ ├── a11y │ │ ├── dialog.json │ │ ├── donate.json │ │ ├── general.json │ │ ├── queue.json │ │ ├── save.json │ │ └── tabs.json │ ├── about.json │ ├── about │ │ ├── credits.md │ │ ├── general.md │ │ ├── privacy.md │ │ └── terms.md │ ├── button.json │ ├── dialog.json │ ├── donate.json │ ├── error.json │ ├── error │ │ ├── api.json │ │ └── queue.json │ ├── general.json │ ├── notification.json │ ├── queue.json │ ├── receiver.json │ ├── remux.json │ ├── save.json │ ├── settings.json │ ├── tabs.json │ └── updates.json ├── languages.json └── ru │ ├── a11y │ ├── general.json │ ├── save.json │ └── tabs.json │ ├── general.json │ ├── save.json │ └── tabs.json ├── package.json ├── src ├── app.css ├── app.d.ts ├── app.html ├── components │ ├── about │ │ └── AboutSupport.svelte │ ├── buttons │ │ ├── ActionButton.svelte │ │ ├── SettingsButton.svelte │ │ ├── SettingsToggle.svelte │ │ ├── Switcher.svelte │ │ └── VerticalActionButton.svelte │ ├── changelog │ │ ├── ChangelogEntry.svelte │ │ └── ChangelogEntryWrapper.svelte │ ├── dialog │ │ ├── DialogBackdropClose.svelte │ │ ├── DialogButton.svelte │ │ ├── DialogButtons.svelte │ │ ├── DialogContainer.svelte │ │ ├── DialogHolder.svelte │ │ ├── NoScriptDialog.svelte │ │ ├── PickerDialog.svelte │ │ ├── PickerItem.svelte │ │ ├── SavingDialog.svelte │ │ ├── SavingTutorial.svelte │ │ └── SmallDialog.svelte │ ├── donate │ │ ├── DonateAltItem.svelte │ │ ├── DonateBanner.svelte │ │ ├── DonateCardContainer.svelte │ │ ├── DonateOptionsCard.svelte │ │ ├── DonateShareCard.svelte │ │ └── DonationOption.svelte │ ├── icons │ │ ├── Clipboard.svelte │ │ ├── Cobalt.svelte │ │ ├── CobaltQR.svelte │ │ ├── CobaltSticker.svelte │ │ ├── Imput.svelte │ │ ├── Music.svelte │ │ ├── Mute.svelte │ │ └── Sparkles.svelte │ ├── misc │ │ ├── AboutPageWrapper.svelte │ │ ├── BetaTesters.svelte │ │ ├── BulletExplain.svelte │ │ ├── CopyIcon.svelte │ │ ├── DropReceiver.svelte │ │ ├── FileReceiver.svelte │ │ ├── Meowbalt.svelte │ │ ├── NotchSticker.svelte │ │ ├── OuterLink.svelte │ │ ├── Placeholder.svelte │ │ ├── PopoverContainer.svelte │ │ ├── SectionHeading.svelte │ │ ├── Skeleton.svelte │ │ ├── Toggle.svelte │ │ ├── Turnstile.svelte │ │ └── UpdateNotification.svelte │ ├── queue │ │ ├── ProcessingQueue.svelte │ │ ├── ProcessingQueueItem.svelte │ │ ├── ProcessingQueueStub.svelte │ │ ├── ProcessingStatus.svelte │ │ └── ProgressBar.svelte │ ├── save │ │ ├── CaptchaTooltip.svelte │ │ ├── Omnibox.svelte │ │ ├── OmniboxIcon.svelte │ │ ├── SupportedServices.svelte │ │ └── buttons │ │ │ ├── ClearButton.svelte │ │ │ └── DownloadButton.svelte │ ├── settings │ │ ├── ClearStorageButton.svelte │ │ ├── DataSettingsButton.svelte │ │ ├── FilenamePreview.svelte │ │ ├── ManageSettings.svelte │ │ ├── ResetSettingsButton.svelte │ │ ├── SettingsCategory.svelte │ │ ├── SettingsDropdown.svelte │ │ └── SettingsInput.svelte │ ├── sidebar │ │ ├── CobaltLogo.svelte │ │ ├── Sidebar.svelte │ │ └── SidebarTab.svelte │ └── subnav │ │ ├── PageNav.svelte │ │ ├── PageNavSection.svelte │ │ └── PageNavTab.svelte ├── fonts │ └── noto-mono-cobalt.css ├── lib │ ├── api │ │ ├── api-url.ts │ │ ├── api.ts │ │ ├── safety-warning.ts │ │ ├── saving-handler.ts │ │ ├── server-info.ts │ │ ├── session.ts │ │ └── turnstile.ts │ ├── changelogs.ts │ ├── clipboard.ts │ ├── device.ts │ ├── download.ts │ ├── env.ts │ ├── haptics.ts │ ├── i18n │ │ ├── locale.ts │ │ └── translations.ts │ ├── libav.ts │ ├── polyfills.ts │ ├── polyfills │ │ ├── abortsignal-timeout.ts │ │ └── user-activation.ts │ ├── settings │ │ ├── defaults.ts │ │ ├── lazy-get.ts │ │ ├── migrate-v7.ts │ │ ├── migrate.ts │ │ ├── validate.ts │ │ └── youtube-lang.ts │ ├── state │ │ ├── dialogs.ts │ │ ├── omnibox.ts │ │ ├── queue-visibility.ts │ │ ├── server-info.ts │ │ ├── settings.ts │ │ ├── task-manager │ │ │ ├── current-tasks.ts │ │ │ └── queue.ts │ │ ├── theme.ts │ │ └── turnstile.ts │ ├── storage │ │ ├── index.ts │ │ ├── memory.ts │ │ ├── opfs.ts │ │ └── storage.ts │ ├── subnav.ts │ ├── task-manager │ │ ├── queue.ts │ │ ├── run-worker.ts │ │ ├── runners │ │ │ ├── fetch.ts │ │ │ └── ffmpeg.ts │ │ ├── scheduler.ts │ │ └── workers │ │ │ ├── fetch.ts │ │ │ └── ffmpeg.ts │ ├── types │ │ ├── api.ts │ │ ├── changelogs.ts │ │ ├── dialog.ts │ │ ├── generic.ts │ │ ├── i18n.ts │ │ ├── libav.ts │ │ ├── meowbalt.ts │ │ ├── omnibox.ts │ │ ├── queue.ts │ │ ├── settings.ts │ │ ├── settings │ │ │ ├── v2.ts │ │ │ ├── v3.ts │ │ │ ├── v4.ts │ │ │ └── v5.ts │ │ ├── task-manager.ts │ │ └── workers.ts │ ├── util.ts │ └── version.ts └── routes │ ├── +error.svelte │ ├── +layout.svelte │ ├── +layout.ts │ ├── +page.svelte │ ├── _headers │ └── +server.ts │ ├── about │ ├── +layout.svelte │ ├── +page.svelte │ ├── [page] │ │ ├── +page.svelte │ │ └── +page.ts │ └── community │ │ └── +page.svelte │ ├── donate │ └── +page.svelte │ ├── remux │ └── +page.svelte │ ├── settings │ ├── +layout.svelte │ ├── +page.svelte │ ├── accessibility │ │ └── +page.svelte │ ├── advanced │ │ └── +page.svelte │ ├── appearance │ │ └── +page.svelte │ ├── audio │ │ └── +page.svelte │ ├── debug │ │ └── +page.svelte │ ├── instances │ │ └── +page.svelte │ ├── local │ │ └── +page.svelte │ ├── metadata │ │ └── +page.svelte │ ├── privacy │ │ └── +page.svelte │ └── video │ │ └── +page.svelte │ ├── updates │ └── +page.svelte │ └── version.json │ └── +server.ts ├── static ├── favicon.png ├── fonts │ └── noto-mono-cobalt.woff2 ├── icons │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── generic.png │ └── maskable │ │ ├── 128.png │ │ ├── 192.png │ │ ├── 384.png │ │ ├── 48.png │ │ ├── 512.png │ │ ├── 72.png │ │ └── 96.png ├── manifest.json ├── meowbalt │ ├── error.png │ ├── fast.png │ ├── question.png │ ├── smile.png │ └── think.png └── update-banners │ ├── bettertogether.webp │ ├── catmakeup.webp │ ├── catphonestand.webp │ ├── catroomba.webp │ ├── catsleep.webp │ ├── catspeed.webp │ ├── catswitchboxes.webp │ ├── cattired.webp │ ├── cobalt10.webp │ ├── developers.webp │ ├── happymeowth.webp │ ├── meowbalt_very_fast.webp │ ├── meowth101hammer.webp │ ├── meowth7eleven.webp │ ├── meowth_beach.webp │ ├── meowthball.webp │ ├── meowthbusinessman.webp │ ├── meowthcenter.webp │ ├── meowthcooking.webp │ ├── meowthhammer.webp │ ├── meowthpolishegg.webp │ ├── meowthproductions.webp │ ├── meowthsnap.webp │ ├── meowthstrong.webp │ ├── millionusers.webp │ ├── newdomain.webp │ ├── newyear2025.webp │ ├── onemillionr.webp │ ├── shutup.webp │ ├── twitchupdate.webp │ └── valentines.webp ├── svelte.config.js ├── tsconfig.json └── vite.config.ts /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[analyzers]] 4 | name = "javascript" 5 | 6 | [analyzers.meta] 7 | environment = ["nodejs"] -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # OS directory info files 2 | .DS_Store 3 | desktop.ini 4 | 5 | # node 6 | node_modules 7 | 8 | # static build 9 | build 10 | 11 | # secrets 12 | .env 13 | .env.* 14 | !.env.example 15 | cookies.json 16 | 17 | # docker 18 | docker-compose.yml 19 | 20 | # ide 21 | .vscode 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-main-instance.yml: -------------------------------------------------------------------------------- 1 | name: main instance bug report 2 | description: "report an issue with cobalt.tools or api.cobalt.tools" 3 | labels: ["main instance issue"] 4 | body: 5 | - type: textarea 6 | id: bug-description 7 | attributes: 8 | label: bug description 9 | description: "clear and concise description of what the issue is." 10 | validations: 11 | required: true 12 | - type: textarea 13 | id: repro-steps 14 | attributes: 15 | label: reproduction steps 16 | description: steps to reproduce the described behavior. 17 | placeholder: | 18 | 1. go to '...' 19 | 2. click on '....' 20 | 3. download [media type] from [service] 21 | 4. see error 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: screenshots 26 | attributes: 27 | label: screenshots 28 | description: if applicable, add screenshots or screen recordings to support your explanation. 29 | - type: textarea 30 | id: links 31 | attributes: 32 | label: links 33 | description: if applicable, add links that cause the issue. more = better. 34 | render: shell 35 | - type: input 36 | id: platform 37 | attributes: 38 | label: platform information 39 | description: "the operating system, browser and their versions where you encounter the issue" 40 | placeholder: safari 7 on mac os x 10.8 41 | validations: 42 | required: true 43 | - type: textarea 44 | id: more-context 45 | attributes: 46 | label: additional context 47 | description: add any other context about the problem here if applicable. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: bug report 2 | description: report a global issue with the cobalt codebase 3 | labels: ["bug"] 4 | body: 5 | - type: textarea 6 | id: bug-description 7 | attributes: 8 | label: bug description 9 | description: "clear and concise description of what the issue is." 10 | validations: 11 | required: true 12 | - type: textarea 13 | id: repro-steps 14 | attributes: 15 | label: reproduction steps 16 | description: steps to reproduce the described behavior. 17 | placeholder: | 18 | 1. go to '...' 19 | 2. click on '....' 20 | 3. download [media type] from [service] 21 | 4. see error 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: screenshots 26 | attributes: 27 | label: screenshots 28 | description: if applicable, add screenshots or screen recordings to support your explanation. 29 | - type: textarea 30 | id: links 31 | attributes: 32 | label: links 33 | description: if applicable, add links that cause the issue. more = better. 34 | render: shell 35 | - type: input 36 | id: platform 37 | attributes: 38 | label: platform information 39 | description: "the operating system, browser and their versions where you encounter the issue" 40 | placeholder: safari 7 on mac os x 10.8 41 | validations: 42 | required: true 43 | - type: textarea 44 | id: more-context 45 | attributes: 46 | label: additional context 47 | description: add any other context about the problem here if applicable. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: discord community 4 | url: https://discord.gg/pQPt8HBUPu 5 | about: | 6 | ask questions and discuss cobalt with others at any time. 7 | usually faster responses as more people are there to help. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: feature request 2 | description: suggest a feature for cobalt 3 | labels: ["feature request"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | thanks for taking the time to make a feature request! 9 | before you start, please make to read the "adding features or support for services" section of 10 | our [contributor guidelines](https://github.com/imputnet/cobalt/blob/main/CONTRIBUTING.md#adding-features-or-support-for-services) to make sure your request is a good fit for cobalt. 11 | - type: textarea 12 | id: feat-description 13 | attributes: 14 | label: describe the feature you'd like to see 15 | description: "clear and concise description of the feature you want to see in cobalt." 16 | validations: 17 | required: true 18 | - type: textarea 19 | id: more-context 20 | attributes: 21 | label: additional context 22 | description: add any other context about the problem here if applicable. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/hosting-help.yml: -------------------------------------------------------------------------------- 1 | name: instance hosting help 2 | description: ask any question regarding cobalt instance hosting 3 | labels: ["instance hosting help"] 4 | body: 5 | - type: textarea 6 | id: problem-description 7 | attributes: 8 | label: problem description 9 | description: | 10 | describe what issue you're having, clearly and concisely. 11 | support your description with screenshots/links/etc when needed. 12 | validations: 13 | required: true 14 | - type: textarea 15 | id: configuration 16 | attributes: 17 | label: your instance configuration 18 | description: | 19 | if applicable, add or describe your instance configuration (e.g. compose file) or any changes you made to it. 20 | please **do not share senstive information** such as secret keys or the contents of your cookies file! 21 | render: shell -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/service-request.yml: -------------------------------------------------------------------------------- 1 | name: service request 2 | description: "request service support in cobalt" 3 | title: "add support for [service name]" 4 | labels: ["service request"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | thanks for taking the time to make a service request! 10 | before you start, please make to read the "adding features or support for services" section of 11 | our [contributor guidelines](https://github.com/imputnet/cobalt/blob/main/CONTRIBUTING.md#adding-features-or-support-for-services) to make sure your request is a good fit for cobalt. 12 | - type: input 13 | id: service-name 14 | attributes: 15 | label: service name 16 | validations: 17 | required: true 18 | - type: textarea 19 | id: service-description 20 | attributes: 21 | label: service description 22 | description: a brief description of what the service is and/or what it provides 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: link-samples 27 | attributes: 28 | label: link samples 29 | description: | 30 | list of links that cobalt should recognize. 31 | could be regular video link, shared video link, mobile video link, shortened link, etc. 32 | render: shell 33 | validations: 34 | required: true 35 | - type: textarea 36 | id: more-context 37 | attributes: 38 | label: additional context 39 | description: any additional context or screenshots should go here. 40 | -------------------------------------------------------------------------------- /.github/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # thx: https://stackoverflow.com/a/27601038 5 | waitport() { 6 | ATTEMPTS=50 7 | while [ $((ATTEMPTS-=1)) -gt 0 ] && ! nc -z localhost $1; do 8 | sleep 0.1 9 | done 10 | 11 | [ "$ATTEMPTS" != 0 ] || exit 1 12 | } 13 | 14 | test_api() { 15 | waitport 3000 16 | curl -m 3 http://localhost:3000/ 17 | API_RESPONSE=$(curl -m 10 http://localhost:3000/ \ 18 | -X POST \ 19 | -H "Accept: application/json" \ 20 | -H "Content-Type: application/json" \ 21 | -d '{"url":"https://garfield-69.tumblr.com/post/696499862852780032","alwaysProxy":true}') 22 | 23 | echo "API_RESPONSE=$API_RESPONSE" 24 | STATUS=$(echo "$API_RESPONSE" | jq -r .status) 25 | STREAM_URL=$(echo "$API_RESPONSE" | jq -r .url) 26 | [ "$STATUS" = tunnel ] || exit 1; 27 | S=$(curl -I -m 10 "$STREAM_URL") 28 | 29 | CONTENT_LENGTH=$(echo "$S" \ 30 | | grep -i content-length \ 31 | | cut -d' ' -f2 \ 32 | | tr -d '\r') 33 | 34 | echo "$CONTENT_LENGTH" 35 | [ "$CONTENT_LENGTH" = 0 ] && exit 1 36 | if [ "$CONTENT_LENGTH" -lt 512 ]; then 37 | exit 1 38 | fi 39 | } 40 | 41 | setup_api() { 42 | export API_PORT=3000 43 | export API_URL=http://localhost:3000 44 | timeout 10 pnpm run --prefix api start & 45 | API_PID=$! 46 | } 47 | 48 | setup_web() { 49 | pnpm run --prefix web check 50 | pnpm run --prefix web build 51 | } 52 | 53 | cd "$(git rev-parse --show-toplevel)" 54 | pnpm install --frozen-lockfile 55 | 56 | if [ "$1" = "api" ]; then 57 | setup_api 58 | test_api 59 | [ "$API_PID" != "" ] \ 60 | && kill "$API_PID" 61 | elif [ "$1" = "web" ]; then 62 | setup_web 63 | else 64 | echo "usage: $0 " >&2 65 | exit 1 66 | fi 67 | 68 | wait || exit $? 69 | -------------------------------------------------------------------------------- /.github/workflows/docker-develop.yml: -------------------------------------------------------------------------------- 1 | name: Build development Docker image 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | REGISTRY: ghcr.io 8 | IMAGE_NAME: ${{ github.repository }} 9 | 10 | jobs: 11 | build-and-push-image: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | packages: write 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v3 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v3 24 | 25 | - name: Log in to the Container registry 26 | uses: docker/login-action@v3 27 | with: 28 | registry: ${{ env.REGISTRY }} 29 | username: ${{ github.actor }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Get release metadata 33 | id: release-meta 34 | run: | 35 | version=$(cat package.json | jq -r .version) 36 | echo "commit_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 37 | echo "version=$version" >> $GITHUB_OUTPUT 38 | echo "major_version=$(echo "$version" | cut -d. -f1)" >> $GITHUB_OUTPUT 39 | - name: Extract metadata (tags, labels) for Docker 40 | id: meta 41 | uses: docker/metadata-action@v5 42 | with: 43 | tags: type=raw,value=develop 44 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 45 | 46 | - name: Build and push Docker image 47 | uses: docker/build-push-action@v6 48 | with: 49 | context: . 50 | platforms: linux/amd64 51 | push: true 52 | tags: ${{ steps.meta.outputs.tags }} 53 | labels: ${{ steps.meta.outputs.labels }} 54 | cache-from: type=gha 55 | cache-to: type=gha,mode=max 56 | -------------------------------------------------------------------------------- /.github/workflows/docker-staging.yml: -------------------------------------------------------------------------------- 1 | name: Build staging Docker image 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | REGISTRY: ghcr.io 8 | IMAGE_NAME: ${{ github.repository }} 9 | 10 | jobs: 11 | build-and-push-image: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | packages: write 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v3 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v3 24 | 25 | - name: Log in to the Container registry 26 | uses: docker/login-action@v3 27 | with: 28 | registry: ${{ env.REGISTRY }} 29 | username: ${{ github.actor }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Get release metadata 33 | id: release-meta 34 | run: | 35 | version=$(cat package.json | jq -r .version) 36 | echo "commit_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 37 | echo "version=$version" >> $GITHUB_OUTPUT 38 | echo "major_version=$(echo "$version" | cut -d. -f1)" >> $GITHUB_OUTPUT 39 | - name: Extract metadata (tags, labels) for Docker 40 | id: meta 41 | uses: docker/metadata-action@v5 42 | with: 43 | tags: type=raw,value=staging 44 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 45 | 46 | - name: Build and push Docker image 47 | uses: docker/build-push-action@v6 48 | with: 49 | context: . 50 | platforms: linux/amd64 51 | push: true 52 | tags: ${{ steps.meta.outputs.tags }} 53 | labels: ${{ steps.meta.outputs.labels }} 54 | cache-from: type=gha 55 | cache-to: type=gha,mode=max 56 | -------------------------------------------------------------------------------- /.github/workflows/fast-forward.yml: -------------------------------------------------------------------------------- 1 | name: fast-forward 2 | on: 3 | issue_comment: 4 | types: [created, edited] 5 | jobs: 6 | fast-forward: 7 | # Only run if the comment contains the /fast-forward command. 8 | if: ${{ contains(github.event.comment.body, '/fast-forward') 9 | && github.event.issue.pull_request }} 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: write 14 | pull-requests: write 15 | issues: write 16 | 17 | steps: 18 | - name: Fast forwarding 19 | uses: sequoia-pgp/fast-forward@v1 20 | with: 21 | merge: true 22 | comment: 'on-error' -------------------------------------------------------------------------------- /.github/workflows/test-services.yml: -------------------------------------------------------------------------------- 1 | name: Run service tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | paths: 7 | - api/** 8 | - packages/** 9 | 10 | jobs: 11 | check-services: 12 | name: test service functionality 13 | runs-on: ubuntu-latest 14 | outputs: 15 | services: ${{ steps.checkServices.outputs.service_list }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: pnpm/action-setup@v4 19 | - id: checkServices 20 | run: pnpm i --frozen-lockfile && echo "service_list=$(node api/src/util/test get-services)" >> "$GITHUB_OUTPUT" 21 | 22 | test-services: 23 | needs: check-services 24 | runs-on: ubuntu-latest 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | service: ${{ fromJson(needs.check-services.outputs.services) }} 29 | name: "test service: ${{ matrix.service }}" 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: pnpm/action-setup@v4 33 | - run: pnpm i --frozen-lockfile && node api/src/util/test run-tests-for ${{ matrix.service }} 34 | env: 35 | API_EXTERNAL_PROXY: ${{ secrets.API_EXTERNAL_PROXY }} 36 | TEST_IGNORE_SERVICES: ${{ vars.TEST_IGNORE_SERVICES }} 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | 7 | jobs: 8 | check-lockfile: 9 | name: check lockfile correctness 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: pnpm/action-setup@v4 14 | - name: Check that lockfile does not need an update 15 | run: pnpm install --frozen-lockfile 16 | 17 | test-web: 18 | name: web sanity check 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: 'lts/*' 25 | - uses: pnpm/action-setup@v4 26 | - run: .github/test.sh web 27 | env: 28 | WEB_DEFAULT_API: ${{ vars.WEB_DEFAULT_API }} 29 | 30 | test-api: 31 | name: api sanity check 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: pnpm/action-setup@v4 36 | - run: .github/test.sh api -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS directory info files 2 | .DS_Store 3 | desktop.ini 4 | 5 | # node 6 | node_modules 7 | 8 | # static build 9 | build 10 | 11 | # secrets 12 | .env 13 | .env.* 14 | !.env.example 15 | cookies.json 16 | keys.json 17 | 18 | # docker 19 | docker-compose.yml 20 | 21 | # ide 22 | .vscode 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:23-alpine AS base 2 | ENV PNPM_HOME="/pnpm" 3 | ENV PATH="$PNPM_HOME:$PATH" 4 | 5 | FROM base AS build 6 | WORKDIR /app 7 | COPY . /app 8 | 9 | RUN corepack enable 10 | RUN apk add --no-cache python3 alpine-sdk 11 | 12 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ 13 | pnpm install --prod --frozen-lockfile 14 | 15 | RUN pnpm deploy --filter=@imput/cobalt-api --prod /prod/api 16 | 17 | FROM base AS api 18 | WORKDIR /app 19 | 20 | COPY --from=build --chown=node:node /prod/api /app 21 | COPY --from=build --chown=node:node /app/.git /app/.git 22 | 23 | USER node 24 | 25 | EXPOSE 9000 26 | CMD [ "node", "src/cobalt" ] 27 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@imput/cobalt-api", 3 | "description": "save what you love", 4 | "version": "11.0.2", 5 | "author": "imput", 6 | "exports": "./src/cobalt.js", 7 | "type": "module", 8 | "engines": { 9 | "node": ">=18" 10 | }, 11 | "scripts": { 12 | "start": "node src/cobalt", 13 | "test": "node src/util/test", 14 | "token:jwt": "node src/util/generate-jwt-secret" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/imputnet/cobalt.git" 19 | }, 20 | "license": "AGPL-3.0", 21 | "bugs": { 22 | "url": "https://github.com/imputnet/cobalt/issues" 23 | }, 24 | "homepage": "https://github.com/imputnet/cobalt#readme", 25 | "dependencies": { 26 | "@datastructures-js/priority-queue": "^6.3.1", 27 | "@imput/psl": "^2.0.4", 28 | "@imput/version-info": "workspace:^", 29 | "content-disposition-header": "0.6.0", 30 | "cors": "^2.8.5", 31 | "dotenv": "^16.0.1", 32 | "express": "^4.21.2", 33 | "express-rate-limit": "^7.4.1", 34 | "ffmpeg-static": "^5.1.0", 35 | "hls-parser": "^0.10.7", 36 | "ipaddr.js": "2.2.0", 37 | "mime": "^4.0.4", 38 | "nanoid": "^5.0.9", 39 | "set-cookie-parser": "2.6.0", 40 | "undici": "^5.19.1", 41 | "url-pattern": "1.0.3", 42 | "youtubei.js": "^13.4.0", 43 | "zod": "^3.23.8" 44 | }, 45 | "optionalDependencies": { 46 | "freebind": "^0.2.2", 47 | "rate-limit-redis": "^4.2.0", 48 | "redis": "^4.7.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /api/src/cobalt.js: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | 3 | import express from "express"; 4 | import cluster from "node:cluster"; 5 | 6 | import path from "path"; 7 | import { fileURLToPath } from "url"; 8 | 9 | import { env, isCluster } from "./config.js" 10 | import { Red } from "./misc/console-text.js"; 11 | import { initCluster } from "./misc/cluster.js"; 12 | 13 | const app = express(); 14 | 15 | const __filename = fileURLToPath(import.meta.url); 16 | const __dirname = path.dirname(__filename).slice(0, -4); 17 | 18 | app.disable("x-powered-by"); 19 | 20 | if (env.apiURL) { 21 | const { runAPI } = await import("./core/api.js"); 22 | 23 | if (isCluster) { 24 | await initCluster(); 25 | } 26 | 27 | runAPI(express, app, __dirname, cluster.isPrimary); 28 | } else { 29 | console.log( 30 | Red("API_URL env variable is missing, cobalt api can't start.") 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /api/src/config.js: -------------------------------------------------------------------------------- 1 | import { getVersion } from "@imput/version-info"; 2 | import { loadEnvs, validateEnvs, setupEnvWatcher } from "./core/env.js"; 3 | 4 | const version = await getVersion(); 5 | 6 | const env = loadEnvs(); 7 | 8 | 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"; 9 | const cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`; 10 | 11 | export const canonicalEnv = Object.freeze(structuredClone(process.env)); 12 | export const setTunnelPort = (port) => env.tunnelPort = port; 13 | export const isCluster = env.instanceCount > 1; 14 | export const updateEnv = (newEnv) => { 15 | // tunnelPort is special and needs to get carried over here 16 | newEnv.tunnelPort = env.tunnelPort; 17 | 18 | for (const key in env) { 19 | env[key] = newEnv[key]; 20 | } 21 | } 22 | 23 | await validateEnvs(env); 24 | 25 | if (env.envFile) { 26 | setupEnvWatcher(); 27 | } 28 | 29 | export { 30 | env, 31 | genericUserAgent, 32 | cobaltUserAgent, 33 | } 34 | -------------------------------------------------------------------------------- /api/src/core/itunnel.js: -------------------------------------------------------------------------------- 1 | import stream from "../stream/stream.js"; 2 | import { getInternalTunnel } from "../stream/manage.js"; 3 | import { setTunnelPort } from "../config.js"; 4 | import { Green } from "../misc/console-text.js"; 5 | import express from "express"; 6 | 7 | const validateTunnel = (req, res) => { 8 | if (!req.ip.endsWith('127.0.0.1')) { 9 | res.sendStatus(403); 10 | return; 11 | } 12 | 13 | if (String(req.query.id).length !== 21) { 14 | res.sendStatus(400); 15 | return; 16 | } 17 | 18 | const streamInfo = getInternalTunnel(req.query.id); 19 | if (!streamInfo) { 20 | res.sendStatus(404); 21 | return; 22 | } 23 | 24 | return streamInfo; 25 | } 26 | 27 | const streamTunnel = (req, res) => { 28 | const streamInfo = validateTunnel(req, res); 29 | if (!streamInfo) { 30 | return; 31 | } 32 | 33 | streamInfo.headers = new Map([ 34 | ...(streamInfo.headers || []), 35 | ...Object.entries(req.headers) 36 | ]); 37 | 38 | return stream(res, { type: 'internal', data: streamInfo }); 39 | } 40 | 41 | export const setupTunnelHandler = () => { 42 | const tunnelHandler = express(); 43 | 44 | tunnelHandler.get('/itunnel', streamTunnel); 45 | 46 | // fallback 47 | tunnelHandler.use((_, res) => res.sendStatus(400)); 48 | // error handler 49 | tunnelHandler.use((_, __, res, ____) => res.socket.end()); 50 | 51 | 52 | const server = tunnelHandler.listen({ 53 | port: 0, 54 | host: '127.0.0.1', 55 | exclusive: true 56 | }, () => { 57 | const { port } = server.address(); 58 | console.log(`${Green('[✓]')} internal tunnel handler running on 127.0.0.1:${port}`); 59 | setTunnelPort(port); 60 | }); 61 | } 62 | -------------------------------------------------------------------------------- /api/src/misc/console-text.js: -------------------------------------------------------------------------------- 1 | const ANSI = { 2 | RESET: "\x1b[0m", 3 | BRIGHT: "\x1b[1m", 4 | RED: "\x1b[31m", 5 | GREEN: "\x1b[32m", 6 | CYAN: "\x1b[36m", 7 | YELLOW: "\x1b[93m" 8 | } 9 | 10 | function wrap(color, text) { 11 | if (!ANSI[color.toUpperCase()]) { 12 | throw "invalid color"; 13 | } 14 | 15 | return ANSI[color.toUpperCase()] + text + ANSI.RESET; 16 | } 17 | 18 | export function Bright(text) { 19 | return wrap('bright', text); 20 | } 21 | 22 | export function Red(text) { 23 | return wrap('red', text); 24 | } 25 | 26 | export function Green(text) { 27 | return wrap('green', text); 28 | } 29 | 30 | export function Cyan(text) { 31 | return wrap('cyan', text); 32 | } 33 | 34 | export function Yellow(text) { 35 | return wrap('yellow', text); 36 | } 37 | -------------------------------------------------------------------------------- /api/src/misc/crypto.js: -------------------------------------------------------------------------------- 1 | import { createCipheriv, createDecipheriv } from "crypto"; 2 | 3 | const algorithm = "aes256"; 4 | 5 | export function encryptStream(plaintext, iv, secret) { 6 | const buff = Buffer.from(JSON.stringify(plaintext)); 7 | const key = Buffer.from(secret, "base64url"); 8 | const cipher = createCipheriv(algorithm, key, Buffer.from(iv, "base64url")); 9 | 10 | return Buffer.concat([ cipher.update(buff), cipher.final() ]) 11 | } 12 | 13 | export function decryptStream(ciphertext, iv, secret) { 14 | const buff = Buffer.from(ciphertext); 15 | const key = Buffer.from(secret, "base64url"); 16 | const decipher = createDecipheriv(algorithm, key, Buffer.from(iv, "base64url")); 17 | 18 | return Buffer.concat([ decipher.update(buff), decipher.final() ]) 19 | } 20 | -------------------------------------------------------------------------------- /api/src/misc/file-watcher.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'node:events'; 2 | import * as fs from 'node:fs/promises'; 3 | 4 | export class FileWatcher extends EventEmitter { 5 | #path; 6 | #hasWatcher = false; 7 | #lastChange = new Date().getTime(); 8 | 9 | constructor({ path, ...rest }) { 10 | super(rest); 11 | this.#path = path; 12 | } 13 | 14 | async #setupWatcher() { 15 | if (this.#hasWatcher) 16 | return; 17 | 18 | this.#hasWatcher = true; 19 | const watcher = fs.watch(this.#path); 20 | for await (const _ of watcher) { 21 | if (new Date() - this.#lastChange > 50) { 22 | this.emit('file-updated'); 23 | this.#lastChange = new Date().getTime(); 24 | } 25 | } 26 | } 27 | 28 | read() { 29 | this.#setupWatcher(); 30 | return fs.readFile(this.#path, 'utf8'); 31 | } 32 | 33 | static fromFileProtocol(url_) { 34 | const url = new URL(url_); 35 | if (url.protocol !== 'file:') { 36 | return; 37 | } 38 | 39 | const pathname = url.pathname === '/' ? '' : url.pathname; 40 | const file_path = decodeURIComponent(url.host + pathname); 41 | return new this({ path: file_path }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/src/misc/load-from-fs.js: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { join, dirname } from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | const root = join( 6 | dirname(fileURLToPath(import.meta.url)), 7 | '../../' 8 | ); 9 | 10 | export function loadFile(path) { 11 | return fs.readFileSync(join(root, path), 'utf-8') 12 | } 13 | 14 | export function loadJSON(path) { 15 | try { 16 | return JSON.parse(loadFile(path)) 17 | } catch { 18 | return false 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/src/misc/randomize-ciphers.js: -------------------------------------------------------------------------------- 1 | import tls from 'node:tls'; 2 | import { randomBytes } from 'node:crypto'; 3 | 4 | const ORIGINAL_CIPHERS = tls.DEFAULT_CIPHERS; 5 | 6 | // How many ciphers from the top of the list to shuffle. 7 | // The remaining ciphers are left in the original order. 8 | const TOP_N_SHUFFLE = 8; 9 | 10 | // Modified variation of https://stackoverflow.com/a/12646864 11 | const shuffleArray = (array) => { 12 | for (let i = array.length - 1; i > 0; i--) { 13 | const j = randomBytes(4).readUint32LE() % array.length; 14 | [array[i], array[j]] = [array[j], array[i]]; 15 | } 16 | 17 | return array; 18 | } 19 | 20 | export const randomizeCiphers = () => { 21 | do { 22 | const cipherList = ORIGINAL_CIPHERS.split(':'); 23 | const shuffled = shuffleArray(cipherList.slice(0, TOP_N_SHUFFLE)); 24 | const retained = cipherList.slice(TOP_N_SHUFFLE); 25 | 26 | tls.DEFAULT_CIPHERS = [ ...shuffled, ...retained ].join(':'); 27 | } while (tls.DEFAULT_CIPHERS === ORIGINAL_CIPHERS); 28 | } 29 | -------------------------------------------------------------------------------- /api/src/misc/run-test.js: -------------------------------------------------------------------------------- 1 | import { normalizeRequest } from "../processing/request.js"; 2 | import match from "../processing/match.js"; 3 | import { extract } from "../processing/url.js"; 4 | 5 | export async function runTest(url, params, expect) { 6 | const { success, data: normalized } = await normalizeRequest({ url, ...params }); 7 | if (!success) { 8 | throw "invalid request"; 9 | } 10 | 11 | const parsed = extract(normalized.url); 12 | if (parsed === null) { 13 | throw `invalid url: ${normalized.url}`; 14 | } 15 | 16 | const result = await match({ 17 | host: parsed.host, 18 | patternMatch: parsed.patternMatch, 19 | params: normalized, 20 | }); 21 | 22 | let error = []; 23 | if (expect.status !== result.body.status) { 24 | const detail = `${expect.status} (expected) != ${result.body.status} (actual)`; 25 | error.push(`status mismatch: ${detail}`); 26 | 27 | if (result.body.status === 'error') { 28 | error.push(`error code: ${result.body?.error?.code}`); 29 | } 30 | } 31 | 32 | if (expect.errorCode && expect.errorCode !== result.body?.error?.code) { 33 | const detail = `${expect.errorCode} (expected) != ${result.body.error.code} (actual)` 34 | error.push(`error mismatch: ${detail}`); 35 | } 36 | 37 | if (expect.code !== result.status) { 38 | const detail = `${expect.code} (expected) != ${result.status} (actual)`; 39 | error.push(`status code mismatch: ${detail}`); 40 | } 41 | 42 | if (error.length) { 43 | if (result.body.text) { 44 | error.push(`error message: ${result.body.text}`); 45 | } 46 | 47 | throw error.join('\n'); 48 | } 49 | 50 | if (result.body.status === 'tunnel') { 51 | // TODO: stream testing 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /api/src/misc/utils.js: -------------------------------------------------------------------------------- 1 | import { request } from 'undici'; 2 | const redirectStatuses = new Set([301, 302, 303, 307, 308]); 3 | 4 | export async function getRedirectingURL(url, dispatcher, headers) { 5 | const params = { 6 | dispatcher, 7 | method: 'HEAD', 8 | headers, 9 | redirect: 'manual' 10 | }; 11 | 12 | let location = await request(url, params).then(r => { 13 | if (redirectStatuses.has(r.statusCode) && r.headers['location']) { 14 | return r.headers['location']; 15 | } 16 | }).catch(() => null); 17 | 18 | location ??= await fetch(url, params).then(r => { 19 | if (redirectStatuses.has(r.status) && r.headers.has('location')) { 20 | return r.headers.get('location'); 21 | } 22 | }).catch(() => null); 23 | 24 | return location; 25 | } 26 | 27 | export function merge(a, b) { 28 | for (const k of Object.keys(b)) { 29 | if (Array.isArray(b[k])) { 30 | a[k] = [...(a[k] ?? []), ...b[k]]; 31 | } else if (typeof b[k] === 'object') { 32 | a[k] = merge(a[k], b[k]); 33 | } else { 34 | a[k] = b[k]; 35 | } 36 | } 37 | 38 | return a; 39 | } 40 | 41 | export function splitFilenameExtension(filename) { 42 | const parts = filename.split('.'); 43 | const ext = parts.pop(); 44 | 45 | if (!parts.length) { 46 | return [ ext, "" ] 47 | } else { 48 | return [ parts.join('.'), ext ] 49 | } 50 | } 51 | 52 | export function zip(a, b) { 53 | return a.map((value, i) => [ value, b[i] ]); 54 | } 55 | 56 | export function isURL(input) { 57 | try { 58 | new URL(input); 59 | return true; 60 | } catch { 61 | return false; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /api/src/processing/cookie/cookie.js: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert'; 2 | 3 | export default class Cookie { 4 | constructor(input) { 5 | assert(typeof input === 'object'); 6 | this._values = {}; 7 | 8 | for (const [ k, v ] of Object.entries(input)) 9 | this.set(k, v); 10 | } 11 | 12 | set(key, value) { 13 | const old = this._values[key]; 14 | if (old === value) 15 | return false; 16 | 17 | this._values[key] = value; 18 | return true; 19 | } 20 | 21 | unset(keys) { 22 | for (const key of keys) delete this._values[key] 23 | } 24 | 25 | static fromString(str) { 26 | const obj = {}; 27 | 28 | str.split('; ').forEach(cookie => { 29 | const key = cookie.split('=')[0]; 30 | const value = cookie.split('=').splice(1).join('='); 31 | obj[key] = value 32 | }) 33 | 34 | return new Cookie(obj) 35 | } 36 | 37 | toString() { 38 | return Object.entries(this._values).map(([ name, value ]) => `${name}=${value}`).join('; ') 39 | } 40 | 41 | toJSON() { 42 | return this.toString() 43 | } 44 | 45 | values() { 46 | return Object.freeze({ ...this._values }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /api/src/processing/schema.js: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { normalizeURL } from "./url.js"; 3 | 4 | export const apiSchema = z.object({ 5 | url: z.string() 6 | .min(1) 7 | .transform(url => normalizeURL(url)), 8 | 9 | audioBitrate: z.enum( 10 | ["320", "256", "128", "96", "64", "8"] 11 | ).default("128"), 12 | 13 | audioFormat: z.enum( 14 | ["best", "mp3", "ogg", "wav", "opus"] 15 | ).default("mp3"), 16 | 17 | downloadMode: z.enum( 18 | ["auto", "audio", "mute"] 19 | ).default("auto"), 20 | 21 | filenameStyle: z.enum( 22 | ["classic", "pretty", "basic", "nerdy"] 23 | ).default("basic"), 24 | 25 | youtubeVideoCodec: z.enum( 26 | ["h264", "av1", "vp9"] 27 | ).default("h264"), 28 | 29 | videoQuality: z.enum( 30 | ["max", "4320", "2160", "1440", "1080", "720", "480", "360", "240", "144"] 31 | ).default("1080"), 32 | 33 | youtubeDubLang: z.string() 34 | .min(2) 35 | .max(8) 36 | .regex(/^[0-9a-zA-Z\-]+$/) 37 | .optional(), 38 | 39 | disableMetadata: z.boolean().default(false), 40 | 41 | allowH265: z.boolean().default(false), 42 | convertGif: z.boolean().default(true), 43 | tiktokFullAudio: z.boolean().default(false), 44 | 45 | alwaysProxy: z.boolean().default(false), 46 | localProcessing: z.boolean().default(false), 47 | 48 | youtubeHLS: z.boolean().default(false), 49 | youtubeBetterAudio: z.boolean().default(false), 50 | 51 | // temporarily kept for backwards compatibility with cobalt 10 schema 52 | twitterGif: z.boolean().default(false), 53 | tiktokH265: z.boolean().default(false), 54 | }) 55 | .strict(); 56 | -------------------------------------------------------------------------------- /api/src/processing/service-alias.js: -------------------------------------------------------------------------------- 1 | const friendlyNames = { 2 | bsky: "bluesky", 3 | } 4 | 5 | export const friendlyServiceName = (service) => { 6 | if (service in friendlyNames) { 7 | return friendlyNames[service]; 8 | } 9 | return service; 10 | } 11 | -------------------------------------------------------------------------------- /api/src/processing/services/pinterest.js: -------------------------------------------------------------------------------- 1 | import { genericUserAgent } from "../../config.js"; 2 | import { resolveRedirectingURL } from "../url.js"; 3 | 4 | const videoRegex = /"url":"(https:\/\/v1\.pinimg\.com\/videos\/.*?)"/g; 5 | const imageRegex = /src="(https:\/\/i\.pinimg\.com\/.*\.(jpg|gif))"/g; 6 | 7 | export default async function(o) { 8 | let id = o.id; 9 | 10 | if (!o.id && o.shortLink) { 11 | const patternMatch = await resolveRedirectingURL(`https://api.pinterest.com/url_shortener/${o.shortLink}/redirect/`); 12 | id = patternMatch?.id; 13 | } 14 | 15 | if (id.includes("--")) id = id.split("--")[1]; 16 | if (!id) return { error: "fetch.fail" }; 17 | 18 | const html = await fetch(`https://www.pinterest.com/pin/${id}/`, { 19 | headers: { "user-agent": genericUserAgent } 20 | }).then(r => r.text()).catch(() => {}); 21 | 22 | if (!html) return { error: "fetch.fail" }; 23 | 24 | const videoLink = [...html.matchAll(videoRegex)] 25 | .map(([, link]) => link) 26 | .find(a => a.endsWith('.mp4')); 27 | 28 | if (videoLink) return { 29 | urls: videoLink, 30 | filename: `pinterest_${id}.mp4`, 31 | audioFilename: `pinterest_${id}_audio` 32 | } 33 | 34 | const imageLink = [...html.matchAll(imageRegex)] 35 | .map(([, link]) => link) 36 | .find(a => a.endsWith('.jpg') || a.endsWith('.gif')); 37 | 38 | const imageType = imageLink.endsWith(".gif") ? "gif" : "jpg" 39 | 40 | if (imageLink) return { 41 | urls: imageLink, 42 | isPhoto: true, 43 | filename: `pinterest_${id}.${imageType}` 44 | } 45 | 46 | return { error: "fetch.empty" }; 47 | } 48 | -------------------------------------------------------------------------------- /api/src/processing/services/streamable.js: -------------------------------------------------------------------------------- 1 | export default async function(obj) { 2 | let video = await fetch(`https://api.streamable.com/videos/${obj.id}`) 3 | .then(r => r.status === 200 ? r.json() : false) 4 | .catch(() => {}); 5 | 6 | if (!video) return { error: "fetch.empty" }; 7 | 8 | let best = video.files["mp4-mobile"]; 9 | if (video.files.mp4 && (obj.isAudioOnly || obj.quality === "max" || obj.quality >= 720)) { 10 | best = video.files.mp4; 11 | } 12 | 13 | if (best) return { 14 | urls: best.url, 15 | filename: `streamable_${obj.id}_${best.width}x${best.height}.mp4`, 16 | audioFilename: `streamable_${obj.id}_audio`, 17 | fileMetadata: { 18 | title: video.title 19 | } 20 | } 21 | return { error: "fetch.fail" } 22 | } 23 | -------------------------------------------------------------------------------- /api/src/security/jwt.js: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid"; 2 | import { createHmac } from "crypto"; 3 | 4 | import { env } from "../config.js"; 5 | 6 | const toBase64URL = (b) => Buffer.from(b).toString("base64url"); 7 | const fromBase64URL = (b) => Buffer.from(b, "base64url").toString(); 8 | 9 | const makeHmac = (data) => { 10 | return createHmac("sha256", env.jwtSecret) 11 | .update(data) 12 | .digest("base64url"); 13 | } 14 | 15 | const sign = (header, payload) => 16 | makeHmac(`${header}.${payload}`); 17 | 18 | const getIPHash = (ip) => 19 | makeHmac(ip).slice(0, 8); 20 | 21 | const generate = (ip) => { 22 | const exp = Math.floor(new Date().getTime() / 1000) + env.jwtLifetime; 23 | 24 | const header = toBase64URL(JSON.stringify({ 25 | alg: "HS256", 26 | typ: "JWT" 27 | })); 28 | 29 | const payload = toBase64URL(JSON.stringify({ 30 | jti: nanoid(8), 31 | sub: getIPHash(ip), 32 | exp, 33 | })); 34 | 35 | const signature = sign(header, payload); 36 | 37 | return { 38 | token: `${header}.${payload}.${signature}`, 39 | exp: env.jwtLifetime - 2, 40 | }; 41 | } 42 | 43 | const verify = (jwt, ip) => { 44 | const [header, payload, signature] = jwt.split(".", 3); 45 | const timestamp = Math.floor(new Date().getTime() / 1000); 46 | 47 | if ([header, payload, signature].join('.') !== jwt) { 48 | return false; 49 | } 50 | 51 | const verifySignature = sign(header, payload); 52 | 53 | if (verifySignature !== signature) { 54 | return false; 55 | } 56 | 57 | const data = JSON.parse(fromBase64URL(payload)); 58 | 59 | return getIPHash(ip) === data.sub 60 | && timestamp <= data.exp; 61 | } 62 | 63 | export default { 64 | generate, 65 | verify, 66 | } 67 | -------------------------------------------------------------------------------- /api/src/security/turnstile.js: -------------------------------------------------------------------------------- 1 | import { env } from "../config.js"; 2 | 3 | export const verifyTurnstileToken = async (turnstileResponse, ip) => { 4 | const result = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { 5 | method: "POST", 6 | headers: { 7 | "Content-Type": "application/json", 8 | }, 9 | body: JSON.stringify({ 10 | secret: env.turnstileSecret, 11 | response: turnstileResponse, 12 | remoteip: ip, 13 | }), 14 | }) 15 | .then(r => r.json()) 16 | .catch(() => {}); 17 | 18 | return !!result?.success; 19 | } 20 | -------------------------------------------------------------------------------- /api/src/store/base-store.js: -------------------------------------------------------------------------------- 1 | const _stores = new Set(); 2 | 3 | export class Store { 4 | id; 5 | 6 | constructor(name) { 7 | name = name.toUpperCase(); 8 | 9 | if (_stores.has(name)) 10 | throw `${name} store already exists`; 11 | _stores.add(name); 12 | 13 | this.id = name; 14 | } 15 | 16 | async _has(_key) { await Promise.reject("needs implementation"); } 17 | has(key) { 18 | if (typeof key !== 'string') { 19 | key = key.toString(); 20 | } 21 | 22 | return this._has(key); 23 | } 24 | 25 | async _get(_key) { await Promise.reject("needs implementation"); } 26 | async get(key) { 27 | if (typeof key !== 'string') { 28 | key = key.toString(); 29 | } 30 | 31 | const val = await this._get(key); 32 | if (val === null) 33 | return null; 34 | 35 | return val; 36 | } 37 | 38 | async _set(_key, _val, _exp_sec = -1) { await Promise.reject("needs implementation") } 39 | set(key, val, exp_sec = -1) { 40 | if (typeof key !== 'string') { 41 | key = key.toString(); 42 | } 43 | 44 | exp_sec = Math.round(exp_sec); 45 | 46 | return this._set(key, val, exp_sec); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /api/src/store/redis-ratelimit.js: -------------------------------------------------------------------------------- 1 | import { env } from "../config.js"; 2 | 3 | let client, redis, redisLimiter; 4 | 5 | export const createStore = async (name) => { 6 | if (!env.redisURL) return; 7 | 8 | if (!client) { 9 | redis = await import('redis'); 10 | redisLimiter = await import('rate-limit-redis'); 11 | client = redis.createClient({ url: env.redisURL }); 12 | await client.connect(); 13 | } 14 | 15 | return new redisLimiter.default({ 16 | prefix: `RL${name}_`, 17 | sendCommand: (...args) => client.sendCommand(args), 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /api/src/store/redis-store.js: -------------------------------------------------------------------------------- 1 | import { commandOptions, createClient } from "redis"; 2 | import { env } from "../config.js"; 3 | import { Store } from "./base-store.js"; 4 | 5 | export default class RedisStore extends Store { 6 | #client = createClient({ 7 | url: env.redisURL, 8 | }); 9 | #connected; 10 | 11 | constructor(name) { 12 | super(name); 13 | this.#connected = this.#client.connect(); 14 | } 15 | 16 | #keyOf(key) { 17 | return this.id + '_' + key; 18 | } 19 | 20 | async _has(key) { 21 | await this.#connected; 22 | 23 | return this.#client.hExists(key); 24 | } 25 | 26 | async _get(key) { 27 | await this.#connected; 28 | 29 | const valueType = await this.#client.get(this.#keyOf(key) + '_t'); 30 | const value = await this.#client.get( 31 | commandOptions({ returnBuffers: true }), 32 | this.#keyOf(key) 33 | ); 34 | 35 | if (!value) { 36 | return null; 37 | } 38 | 39 | if (valueType === 'b') 40 | return value; 41 | else 42 | return JSON.parse(value); 43 | } 44 | 45 | async _set(key, val, exp_sec = -1) { 46 | await this.#connected; 47 | 48 | const options = exp_sec > 0 ? { EX: exp_sec } : undefined; 49 | 50 | if (val instanceof Buffer) { 51 | await this.#client.set( 52 | this.#keyOf(key) + '_t', 53 | 'b', 54 | options 55 | ); 56 | } 57 | 58 | await this.#client.set( 59 | this.#keyOf(key), 60 | val, 61 | options 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /api/src/store/store.js: -------------------------------------------------------------------------------- 1 | import { env } from '../config.js'; 2 | 3 | let _export; 4 | if (env.redisURL) { 5 | _export = await import('./redis-store.js'); 6 | } else { 7 | _export = await import('./memory-store.js'); 8 | } 9 | 10 | export default _export.default; 11 | -------------------------------------------------------------------------------- /api/src/stream/stream.js: -------------------------------------------------------------------------------- 1 | import stream from "./types.js"; 2 | 3 | import { closeResponse } from "./shared.js"; 4 | import { internalStream } from "./internal.js"; 5 | 6 | export default async function(res, streamInfo) { 7 | try { 8 | switch (streamInfo.type) { 9 | case "proxy": 10 | return await stream.proxy(streamInfo, res); 11 | 12 | case "internal": 13 | return await internalStream(streamInfo.data, res); 14 | 15 | case "merge": 16 | return await stream.merge(streamInfo, res); 17 | 18 | case "remux": 19 | case "mute": 20 | return await stream.remux(streamInfo, res); 21 | 22 | case "audio": 23 | return await stream.convertAudio(streamInfo, res); 24 | 25 | case "gif": 26 | return await stream.convertGif(streamInfo, res); 27 | } 28 | 29 | closeResponse(res); 30 | } catch { 31 | closeResponse(res); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /api/src/util/generate-jwt-secret.js: -------------------------------------------------------------------------------- 1 | // run with `pnpm -r token:jwt` 2 | 3 | const makeSecureString = (length = 64) => { 4 | const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; 5 | const out = []; 6 | 7 | while (out.length < length) { 8 | for (const byte of crypto.getRandomValues(new Uint8Array(length))) { 9 | if (byte < alphabet.length) { 10 | out.push(alphabet[byte]); 11 | } 12 | 13 | if (out.length === length) { 14 | break; 15 | } 16 | } 17 | } 18 | 19 | return out.join(''); 20 | } 21 | 22 | console.log(`JWT_SECRET: ${JSON.stringify(makeSecureString(64))}`) 23 | -------------------------------------------------------------------------------- /api/src/util/tests/bilibili.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "1080p video", 4 | "url": "https://www.bilibili.com/video/BV18i4y1m7xV/", 5 | "params": {}, 6 | "expected": { 7 | "code": 200, 8 | "status": "tunnel" 9 | } 10 | }, 11 | { 12 | "name": "1080p video muted", 13 | "url": "https://www.bilibili.com/video/BV18i4y1m7xV/", 14 | "params": { 15 | "downloadMode": "mute" 16 | }, 17 | "expected": { 18 | "code": 200, 19 | "status": "tunnel" 20 | } 21 | }, 22 | { 23 | "name": "1080p vertical video", 24 | "url": "https://www.bilibili.com/video/BV1uu411z7VV/", 25 | "params": {}, 26 | "expected": { 27 | "code": 200, 28 | "status": "tunnel" 29 | } 30 | }, 31 | { 32 | "name": "1080p vertical video muted", 33 | "url": "https://www.bilibili.com/video/BV1uu411z7VV/", 34 | "params": { 35 | "downloadMode": "mute" 36 | }, 37 | "expected": { 38 | "code": 200, 39 | "status": "tunnel" 40 | } 41 | }, 42 | { 43 | "name": "b23.tv shortlink", 44 | "url": "https://b23.tv/av32430100", 45 | "params": {}, 46 | "expected": { 47 | "code": 200, 48 | "status": "tunnel" 49 | } 50 | }, 51 | { 52 | "name": "bilibili.tv link", 53 | "url": "https://www.bilibili.tv/en/video/4789599404426256", 54 | "params": {}, 55 | "expected": { 56 | "code": 200, 57 | "status": "tunnel" 58 | } 59 | } 60 | ] 61 | -------------------------------------------------------------------------------- /api/src/util/tests/dailymotion.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "regular video", 4 | "url": "https://www.dailymotion.com/video/x8t1eho", 5 | "params": {}, 6 | "expected": { 7 | "code": 200, 8 | "status": "tunnel" 9 | } 10 | }, 11 | { 12 | "name": "private video", 13 | "url": "https://www.dailymotion.com/video/k41fZWpx2TaAORA2nok", 14 | "params": {}, 15 | "expected": { 16 | "code": 200, 17 | "status": "tunnel" 18 | } 19 | }, 20 | { 21 | "name": "dai.ly shortened link", 22 | "url": "https://dai.ly/k41fZWpx2TaAORA2nok", 23 | "params": {}, 24 | "expected": { 25 | "code": 200, 26 | "status": "tunnel" 27 | } 28 | } 29 | ] -------------------------------------------------------------------------------- /api/src/util/tests/facebook.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "direct video with username and id", 4 | "url": "https://web.facebook.com/100048111287134/videos/1157798148685638/", 5 | "params": {}, 6 | "expected": { 7 | "code": 200, 8 | "status": "redirect" 9 | } 10 | }, 11 | { 12 | "name": "direct video with id as query param", 13 | "url": "https://web.facebook.com/watch/?v=883839773514682&ref=sharing", 14 | "params": {}, 15 | "expected": { 16 | "code": 200, 17 | "status": "redirect" 18 | } 19 | }, 20 | { 21 | "name": "direct video with caption", 22 | "url": "https://web.facebook.com/wood57/videos/𝐒𝐞𝐛𝐚𝐬𝐤𝐨𝐦-𝐟𝐮𝐥𝐥/883839773514682", 23 | "params": {}, 24 | "expected": { 25 | "code": 200, 26 | "status": "redirect" 27 | } 28 | }, 29 | { 30 | "name": "shortlink video", 31 | "url": "https://fb.watch/r1K6XHMfGT/", 32 | "params": {}, 33 | "expected": { 34 | "code": 200, 35 | "status": "redirect" 36 | } 37 | }, 38 | { 39 | "name": "reel video", 40 | "url": "https://web.facebook.com/reel/730293269054758", 41 | "params": {}, 42 | "expected": { 43 | "code": 200, 44 | "status": "redirect" 45 | } 46 | }, 47 | { 48 | "name": "shared video link", 49 | "url": "https://www.facebook.com/share/v/6EJK4Z8EAEAHtz8K/", 50 | "params": {}, 51 | "expected": { 52 | "code": 200, 53 | "status": "redirect" 54 | } 55 | }, 56 | { 57 | "name": "shared video link v2", 58 | "url": "https://web.facebook.com/share/r/JFZfPVgLkiJQmWrr/", 59 | "params": {}, 60 | "expected": { 61 | "code": 200, 62 | "status": "redirect" 63 | } 64 | } 65 | ] 66 | -------------------------------------------------------------------------------- /api/src/util/tests/loom.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "1080p video", 4 | "url": "https://www.loom.com/share/d165fd054a294d8a8587807bcc50c885", 5 | "params": {}, 6 | "expected": { 7 | "code": 200, 8 | "status": "redirect" 9 | } 10 | }, 11 | { 12 | "name": "1080p video (muted)", 13 | "url": "https://www.loom.com/share/d165fd054a294d8a8587807bcc50c885", 14 | "params": { 15 | "downloadMode": "mute" 16 | }, 17 | "expected": { 18 | "code": 200, 19 | "status": "tunnel" 20 | } 21 | }, 22 | { 23 | "name": "1080p video (audio only)", 24 | "url": "https://www.loom.com/share/d165fd054a294d8a8587807bcc50c885", 25 | "params": { 26 | "downloadMode": "audio" 27 | }, 28 | "expected": { 29 | "code": 400, 30 | "status": "error" 31 | } 32 | }, 33 | { 34 | "name": "video with no transcodedUrl", 35 | "url": "https://www.loom.com/share/aa3d8b08bee74d05af5b42989e9f33e9", 36 | "params": {}, 37 | "expected": { 38 | "code": 200, 39 | "status": "redirect" 40 | } 41 | }, 42 | { 43 | "name": "video with title in url", 44 | "url": "https://www.loom.com/share/Meet-AI-workflows-aa3d8b08bee74d05af5b42989e9f33e9", 45 | "params": {}, 46 | "expected": { 47 | "code": 200, 48 | "status": "redirect" 49 | } 50 | }, 51 | { 52 | "name": "video with title in url (2)", 53 | "url": "https://www.loom.com/share/Unlocking-Incredible-Organizational-Velocity-with-Async-Video-4a2a8baf124c4390954dcbb46a58cfd7", 54 | "params": {}, 55 | "expected": { 56 | "code": 200, 57 | "status": "redirect" 58 | } 59 | } 60 | ] 61 | -------------------------------------------------------------------------------- /api/src/util/tests/ok.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "regular video", 4 | "url": "https://ok.ru/video/7204071410346", 5 | "params": {}, 6 | "expected": { 7 | "code": 200, 8 | "status": "tunnel" 9 | } 10 | } 11 | ] -------------------------------------------------------------------------------- /api/src/util/tests/snapchat.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "spotlight", 4 | "url": "https://www.snapchat.com/spotlight/W7_EDlXWTBiXAEEniNoMPwAAYdWxucG9pZmNqAY46j0a5AY46j0YbAAAAAQ", 5 | "params": {}, 6 | "expected": { 7 | "code": 200, 8 | "status": "redirect" 9 | } 10 | }, 11 | { 12 | "name": "shortlinked spotlight", 13 | "url": "https://t.snapchat.com/4ZsiBLDi", 14 | "params": {}, 15 | "expected": { 16 | "code": 200, 17 | "status": "redirect" 18 | } 19 | }, 20 | { 21 | "name": "story", 22 | "url": "https://www.snapchat.com/add/bazerkmakane", 23 | "params": {}, 24 | "expected": { 25 | "code": 200, 26 | "status": "picker" 27 | } 28 | } 29 | ] 30 | -------------------------------------------------------------------------------- /api/src/util/tests/streamable.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "regular video", 4 | "url": "https://streamable.com/p9cln4", 5 | "params": {}, 6 | "expected": { 7 | "code": 200, 8 | "status": "redirect" 9 | } 10 | }, 11 | { 12 | "name": "embedded link", 13 | "url": "https://streamable.com/e/rsmo56", 14 | "params": {}, 15 | "expected": { 16 | "code": 200, 17 | "status": "redirect" 18 | } 19 | }, 20 | { 21 | "name": "regular video (isAudioOnly)", 22 | "url": "https://streamable.com/p9cln4", 23 | "params": { 24 | "downloadMode": "audio" 25 | }, 26 | "expected": { 27 | "code": 200, 28 | "status": "tunnel" 29 | } 30 | }, 31 | { 32 | "name": "regular video (isAudioMuted)", 33 | "url": "https://streamable.com/p9cln4", 34 | "params": { 35 | "downloadMode": "mute" 36 | }, 37 | "expected": { 38 | "code": 200, 39 | "status": "tunnel" 40 | } 41 | }, 42 | { 43 | "name": "inexistent video", 44 | "url": "https://streamable.com/XXXXXX", 45 | "params": {}, 46 | "expected": { 47 | "code": 400, 48 | "status": "error" 49 | } 50 | } 51 | ] -------------------------------------------------------------------------------- /api/src/util/tests/tiktok.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "long link video", 4 | "url": "https://www.tiktok.com/@fatfatmillycat/video/7195741644585454894", 5 | "params": {}, 6 | "expected": { 7 | "code": 200, 8 | "status": "tunnel" 9 | } 10 | }, 11 | { 12 | "name": "images", 13 | "url": "https://www.tiktok.com/@matryoshk4/video/7231234675476532526", 14 | "params": {}, 15 | "expected": { 16 | "code": 200, 17 | "status": "picker" 18 | } 19 | }, 20 | { 21 | "name": "long link inexistent", 22 | "url": "https://www.tiktok.com/@blablabla/video/7120851458451417478", 23 | "params": {}, 24 | "expected": { 25 | "code": 400, 26 | "status": "error" 27 | } 28 | }, 29 | { 30 | "name": "short link inexistent", 31 | "url": "https://vt.tiktok.com/2p4ewa7/", 32 | "params": {}, 33 | "expected": { 34 | "code": 400, 35 | "status": "error" 36 | } 37 | }, 38 | { 39 | "name": "age restricted video", 40 | "url": "https://www.tiktok.com/@.kyle.films/video/7415757181145877793", 41 | "params": {}, 42 | "expected": { 43 | "code": 400, 44 | "status": "error" 45 | } 46 | } 47 | ] -------------------------------------------------------------------------------- /api/src/util/tests/tumblr.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "at.tumblr link", 4 | "url": "https://at.tumblr.com/music/704177038274281472/n7x7pr7x4w2b", 5 | "params": {}, 6 | "expected": { 7 | "code": 200, 8 | "status": "redirect" 9 | } 10 | }, 11 | { 12 | "name": "user subdomain link", 13 | "url": "https://garfield-69.tumblr.com/post/696499862852780032", 14 | "params": {}, 15 | "expected": { 16 | "code": 200, 17 | "status": "redirect" 18 | } 19 | }, 20 | { 21 | "name": "web app link", 22 | "url": "https://www.tumblr.com/rongzhi/707729381162958848/english-added-by-me?source=share", 23 | "params": {}, 24 | "expected": { 25 | "code": 200, 26 | "status": "redirect" 27 | } 28 | }, 29 | { 30 | "name": "tumblr audio", 31 | "url": "https://www.tumblr.com/zedneon/737815079301562368/zedneon-ft-mr-sauceman-tech-n9ne-speed-of?source=share", 32 | "params": {}, 33 | "expected": { 34 | "code": 200, 35 | "status": "tunnel" 36 | } 37 | }, 38 | { 39 | "name": "tumblr video converted to audio", 40 | "url": "https://garfield-69.tumblr.com/post/696499862852780032", 41 | "params": { 42 | "downloadMode": "audio" 43 | }, 44 | "expected": { 45 | "code": 200, 46 | "status": "tunnel" 47 | } 48 | } 49 | ] -------------------------------------------------------------------------------- /api/src/util/tests/twitch.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "clip", 4 | "url": "https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G", 5 | "params": {}, 6 | "expected": { 7 | "code": 200, 8 | "status": "redirect" 9 | } 10 | }, 11 | { 12 | "name": "clip (isAudioOnly)", 13 | "url": "https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G", 14 | "params": { 15 | "downloadMode": "audio" 16 | }, 17 | "expected": { 18 | "code": 200, 19 | "status": "tunnel" 20 | } 21 | }, 22 | { 23 | "name": "clip (isAudioMuted)", 24 | "url": "https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G", 25 | "params": { 26 | "downloadMode": "mute" 27 | }, 28 | "expected": { 29 | "code": 200, 30 | "status": "tunnel" 31 | } 32 | } 33 | ] -------------------------------------------------------------------------------- /api/src/util/tests/vimeo.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "4k progressive", 4 | "url": "https://vimeo.com/288386543", 5 | "params": { 6 | "videoQuality": "2160" 7 | }, 8 | "expected": { 9 | "code": 200, 10 | "status": "redirect" 11 | } 12 | }, 13 | { 14 | "name": "720p progressive", 15 | "url": "https://vimeo.com/288386543", 16 | "params": { 17 | "videoQuality": "720" 18 | }, 19 | "expected": { 20 | "code": 200, 21 | "status": "redirect" 22 | } 23 | }, 24 | { 25 | "name": "1080p dash parcel", 26 | "url": "https://vimeo.com/967252742", 27 | "params": { 28 | "videoQuality": "1440" 29 | }, 30 | "expected": { 31 | "code": 200, 32 | "status": "redirect" 33 | } 34 | }, 35 | { 36 | "name": "720p dash parcel", 37 | "url": "https://vimeo.com/967252742", 38 | "params": { 39 | "videoQuality": "360" 40 | }, 41 | "expected": { 42 | "code": 200, 43 | "status": "redirect" 44 | } 45 | }, 46 | { 47 | "name": "private video", 48 | "url": "https://vimeo.com/903115595/f14d06da38", 49 | "params": {}, 50 | "expected": { 51 | "code": 200, 52 | "status": "redirect" 53 | } 54 | }, 55 | { 56 | "name": "mature video", 57 | "url": "https://vimeo.com/973212054", 58 | "params": {}, 59 | "expected": { 60 | "code": 200, 61 | "status": "redirect" 62 | } 63 | } 64 | ] -------------------------------------------------------------------------------- /docs/examples/cookies.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "instagram": [ 3 | "mid=; ig_did=; csrftoken=; ds_user_id=; sessionid=" 4 | ], 5 | "instagram_bearer": [ 6 | "token=", "token=IGT:2:" 7 | ], 8 | "reddit": [ 9 | "client_id=; client_secret=; refresh_token=" 10 | ], 11 | "twitter": [ 12 | "auth_token=; ct0=" 13 | ], 14 | "youtube": [ 15 | "cookie=; b=" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /docs/images/protect-an-instance/add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/docs/images/protect-an-instance/add.png -------------------------------------------------------------------------------- /docs/images/protect-an-instance/created.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/docs/images/protect-an-instance/created.png -------------------------------------------------------------------------------- /docs/images/protect-an-instance/domain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/docs/images/protect-an-instance/domain.png -------------------------------------------------------------------------------- /docs/images/protect-an-instance/mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/docs/images/protect-an-instance/mode.png -------------------------------------------------------------------------------- /docs/images/protect-an-instance/name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/docs/images/protect-an-instance/name.png -------------------------------------------------------------------------------- /docs/images/protect-an-instance/sidebar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/docs/images/protect-an-instance/sidebar.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cobalt", 3 | "packageManager": "pnpm@9.6.0", 4 | "engines": { 5 | "pnpm": ">=9" 6 | } 7 | } -------------------------------------------------------------------------------- /packages/api-client/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /packages/api-client/.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | dist 3 | -------------------------------------------------------------------------------- /packages/api-client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /packages/api-client/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 imput 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /packages/api-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@imput/cobalt-client", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": {}, 7 | "keywords": [], 8 | "author": "imput ", 9 | "license": "MIT", 10 | "devDependencies": { 11 | "prettier": "3.3.3", 12 | "tsup": "^8.3.0", 13 | "typescript": "^5.4.5" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/api-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "target": "es2016", 5 | "module": "commonjs", 6 | "rootDir": "./src", 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "resolveJsonModule": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "outDir": "./dist" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/version-info/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@imput/version-info" { 2 | export function getCommit(): Promise; 3 | export function getBranch(): Promise; 4 | export function getRemote(): Promise; 5 | export function getVersion(): Promise; 6 | } 7 | -------------------------------------------------------------------------------- /packages/version-info/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@imput/version-info", 3 | "version": "1.0.0", 4 | "description": "helper package for cobalt that provides commit info & version from package file.", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "type": "module", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/imputnet/cobalt.git" 11 | }, 12 | "author": "imput", 13 | "license": "AGPL-3.0", 14 | "bugs": { 15 | "url": "https://github.com/imputnet/cobalt/issues" 16 | }, 17 | "homepage": "https://github.com/imputnet/cobalt#readme" 18 | } 19 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "api" 3 | - "web" 4 | - "packages/*" 5 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # builds 2 | /build 3 | /.svelte-kit 4 | /package 5 | 6 | # vite 7 | vite.config.js.timestamp-* 8 | vite.config.ts.timestamp-* 9 | -------------------------------------------------------------------------------- /web/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /web/changelogs/2.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "everything is new!" 3 | date: "Jun 28, 2022" 4 | --- 5 | 6 | - added support for: bilibili.com, youtube, youtube music, reddit, vk; 7 | - remade the way downloads are handled; 8 | - added proper website branding; 9 | - added settings, donations, and changelog menu; 10 | - added manual theme picker; 11 | - added format picker for youtube; 12 | - added quality picker for youtube and vk downloads (bilibili and twitter later); 13 | - improved usability; 14 | - upgraded the download button to be adaptive depending on current status; 15 | - popups are now adaptive, too; 16 | - better scalability; 17 | - took out trash; 18 | - moved from commonjs to ems; 19 | - overall revamp of backend and frontend; 20 | - fixed various issues that were present in older version. -------------------------------------------------------------------------------- /web/changelogs/2.2.5.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "remade localization system once again" 3 | date: "Jul 24, 2022" 4 | --- 5 | 6 | - new localization system: fast, dynamic, way more organized 7 | - localization strings are WAY more descriptive 8 | - it's now easier to add support for other languages (just one loc file instead of five) 9 | - localization now falls back to english if localized string isnt available 10 | - got rid of all static language selectors (probably) 11 | - slightly updated english and russian strings 12 | - miscellaneous settings items have been bundled together and moved to the bottom, cause they're used the least 13 | - bottom links should no longer touch the popup border on overflow 14 | - rearranged popup order in the rendered page 15 | - bumped version up to 2.2.5 16 | 17 | if you see strings that are like this: !!EXAMPLE!! or withoutspace please file an issue on github 18 | -------------------------------------------------------------------------------- /web/changelogs/2.2.6.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "tiktok is back!" 3 | date: "Jul 28, 2022" 4 | --- 5 | 6 | - added support for tiktok (images won't work, they're only accessible through the app) 7 | - hopefully main input bar is now not rounded on ios, i fucking hate apple 8 | - if service is not supported, a correlating error will appear, not generic one 9 | - removed duplicates from config that are present in package json already 10 | - tiny bit of clean up -------------------------------------------------------------------------------- /web/changelogs/2.2.8.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "faster and more accessible" 3 | date: "Jul 30, 2022" 4 | --- 5 | 6 | - spanish localization by @adrigoomy 7 | - cobalt should load even faster cause all loaded files are now way smaller (esbuild implementation) -------------------------------------------------------------------------------- /web/changelogs/2.2.9.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "fixes" 3 | date: "Aug 6, 2022" 4 | --- 5 | 6 | - fixed neighbor quality picking for youtube videos 7 | - webm is now default for youtube downloads for all platforms except for ios 8 | - even more readme changes 9 | - a tiny bit of clean up 10 | - preparing stuff for next major update -------------------------------------------------------------------------------- /web/changelogs/2.2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "beginning of 2.2" 3 | date: "Jul 13, 2022" 4 | --- 5 | 6 | - added download popup to solve the issue with downloads on ios 7 | - merged big and small popups into one 8 | - made buttons in donation menu act like buttons 9 | - began to clean up localisation 10 | - added ability to embed repo url into localisation strings 11 | - moved ffmpeg args to config for more flexibility (and hopefully future changes) 12 | - removed error response in stream that could result in a crash 13 | - removed notice for ios users from about cause it's no longer relevant 14 | - made error popup look and act like the rest 15 | - a tiny bit of clean up 16 | - changelog is now made out of latest commit (and doesn't break) -------------------------------------------------------------------------------- /web/changelogs/3.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: small quality of life improvements 3 | date: "Aug 16, 2022" 4 | --- 5 | 6 | - tiktok videos can now be downloaded without watermark, you just have to enable it in video settings (+)! 7 | - you now can pass "u" query to main website to fill out the input area right away (co.wukko.me?u=your_link_here). 8 | - added ability to select text in certain areas of website. 9 | - some internal stuff has been cleaned up. 10 | 11 | follow cobalt's twitter account for polls, updates, and more: [@justusecobalt](https://twitter.com/justusecobalt) -------------------------------------------------------------------------------- /web/changelogs/3.2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: ukrainian localization and new error popup 3 | date: "Aug 19, 2022" 4 | --- 5 | 6 | - added ukrainian localization (thanks to löffel). 7 | - new error popup! it's now prettier, more compact, and has an easily accessible close button. 8 | - russian localization has been patched up a bit 9 | - cleaned up css a bit 10 | - added github contributors to made with love message. 11 | - emojis have been tuned to have the same shade of yellow. 12 | - updated translation guidelines in readme a bit. -------------------------------------------------------------------------------- /web/changelogs/3.4.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: tiktok images and better localization 3 | date: "Sep 3, 2022" 4 | --- 5 | 6 | - added ability to save images from tiktok conveniently, and without watermarks. 7 | - it's now way easier to contribute translations to cobalt. read more on how to do it [on github](https://github.com/imputnet/cobalt#how-to-contribute-translations). in short, you don't need to fork the repo anymore, everything is handled through crowdin :D 8 | - updated readme in github repo to make it easier to read and understand. 9 | - began to add more descriptive errors, more to come soon. 10 | 11 | internal stuff: 12 | - remade entirety of tiktok module and merged it with douyin one. now both (basically identical) platforms have perfect parity of download features. 13 | - cleaned up the twitter module, now it's way more compact and easy to read. 14 | - moved changelog out of english localization. 15 | - other small improvements and fixes. -------------------------------------------------------------------------------- /web/changelogs/3.5.2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "vk clips support, improved changelog system, and less bugs" 3 | date: "Sep 11, 2022" 4 | --- 5 | new features: 6 | - added support for vk clips. cobalt now lets you download even more cringy videos! 7 | - added update history right to the changelog menu. it's not loaded by default to minimize page load time, but can be loaded upon pressing a button. probably someone will enjoy this. 8 | - as you've just read, cobalt now has on-demand blocks. they're rendered on server upon request and exist to prevent any unnecessary clutter by default. the first feature to use on-demand rendering is history of updates in changelog tab. 9 | 10 | changes: 11 | - moved twitter entry to about tab and made it localized. 12 | - added clarity to what services exactly are supported in about tab. 13 | 14 | bug fixes: 15 | - cobalt should no longer crash to firefox users if they love to play around with user-agent switching. 16 | - vk videos of any resolution and aspect ratio should now be downloadable. 17 | - vk quality picking has been fixed after vk broke it for parsers on their side. -------------------------------------------------------------------------------- /web/changelogs/3.5.4.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "tiktok support is back :D" 3 | date: "Sep 21, 2022" 4 | --- 5 | you can download videos, sounds, and images from tiktok again! 6 | huge thank you to [@minzique](https://github.com/minzique) for finding another api endpoint that works. -------------------------------------------------------------------------------- /web/changelogs/3.5.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "ui revamp and usability improvements" 3 | date: "Sep 8, 2022" 4 | --- 5 | new features: 6 | - cobalt now lets you paste the link in your clipboard and download the file in a single press of a button.if your clipboard's latest content isn't a valid url, cobalt won't process or paste it. you can also hide the clipboard button in settings if you want to. 7 | unfortunately, the clipboard feature is not available to firefox users because mozilla didn't add proper support for clipboard api. 8 | - there's now a button to quickly clean the input area, right next to download button. it's really useful in case when you want to quickly save a bunch of videos and don't want to bother selecting text. 9 | - keyboard shortcuts! you love them, i love them, and now we can use them to perform quick actions in cobalt. use ctrl+v combo to paste the link without focusing the input area; press escape key to close the active popup or clean the input area; and if you didn't know, you can also press enter to download content from the link. 10 | 11 | new looks: 12 | - main box has been revamped. it has lost its border, thick padding, and now feels light and fresh. 13 | - download button is now prettier, and has been tuned to make >> look just like the logo. 14 | - buttons on the bottom now actually look like buttons and are way more descriptive. no more #@+?$ bullshit. it's way easier to see and understand what each of them does. 15 | - bottom buttons are prettier and easier to use on a phone. they're bigger and stretch out to sides, making them easier to press. 16 | 17 | fixes: 18 | - it's now impossible to overlap multiple popups at once. no more mess if you decide to explore popups while waiting for request to process. 19 | - popup tabs have been slightly moved down to prevent popup content overlapping. 20 | - ui scalability has been improved. -------------------------------------------------------------------------------- /web/changelogs/3.6.3.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "less disturbance" 3 | date: "Oct 5, 2022" 4 | --- 5 | changelog popup no longer annoys you after a major update! this action has been replaced with a notification dot. if you see a red dot, then there's something new. 6 | 7 | your old setting that disabled the changelog popup now applies to notifications. 8 | 9 | new users will see a notification dot instead of an about popup, too. this was mostly done to prevent complications if your browser is set up to clean local storage when you close it. 10 | 11 | other changes: 12 | - popups are now a bit wider, just so more content fits at once. 13 | - better interface scaling. 14 | - code is a bit cleaner now. 15 | - changed twitter api endpoint. there should no longer be any rate limits. -------------------------------------------------------------------------------- /web/changelogs/3.6.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "improvements all around!" 3 | date: "Sep 28, 2022" 4 | --- 5 | - download mode switcher is moving places, it's now right next to link input area. 6 | - smart mode has been renamed to auto mode, because this name is easier to understand. 7 | - all spacings in ui have been evened out. no more eye strain. 8 | - added support for twitter /video/1 links 9 | - clipboard button exception has been redone to prepare for adoption of readtext clipboard api in firefox. 10 | - cobalt is now using different tiktok api endpoint, because previous one got killed, just like the one before. 11 | - "other" settings tab has been cleaned up. -------------------------------------------------------------------------------- /web/changelogs/3.7.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "support for multi media tweets is here!" 3 | date: "Oct 9, 2022" 4 | --- 5 | cobalt now lets you save any of the videos or gifs in a tweet. even if there are many of them. 6 | 7 | simply paste a link like you'd usually do and cobalt will ask what exactly you want to save. 8 | 9 | FIREFOX USERS: if you have strict tracking protection on, you might wanna turn it off for cobalt, or else twitter video previews won't load. firefox filters out twitter image cdn as if it was a tracker, which it's not. it's a false-positive. 10 | 11 | however, you can leave it on if you're fine with blank squares and video numbers. i have thought of that in prior, you're welcome. 12 | 13 | other changes: 14 | - repurposed ex tiktok-only image picker to be dynamic and adapt depending on content to pick. that's exactly how twitter multi media downloads work. 15 | - cobalt is now properly viewable on phones with tiny screens, such as first gen iphone se. 16 | - scrollbars now should be visible only where they're needed. 17 | - brought back proper twitter api, because other one doesn't have multi media stuff (at least yet). 18 | - cleaned up some internal files, including main frontend js file. 19 | - reorganized some files in project directory, now you won't get lost when contributing or just looking through cobalt's code. -------------------------------------------------------------------------------- /web/changelogs/4.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "better and faster than ever" 3 | date: "Oct 24, 2022" 4 | --- 5 | this update has a ton of improvements and new features. 6 | 7 | changes you probably care about: 8 | - cobalt now has support for recorded twitter spaces! download the previous conversation no matter how long it was. 9 | - download speeds from youtube are at least 10 times better now. you're welcome. 10 | - both video and audio length limits have been extended to 2 hours. 11 | - audio downloads from youtube, youtube music, twitter spaces, and soundcloud now have metadata! most often it's just title and artist, but when cobalt is able to get more info, it adds that metadata too. 12 | - tiktok downloads have been fixed, yet again, and if they ever break in the future, cobalt will fall back to downloading a less annoyingly watermarked video. 13 | - soundcloud downloads have been fixed, too. 14 | 15 | less notable changes: 16 | - currently experimenting with using mp3 as default audio format. if you set something other than mp3 before, it'll be set to mp3. you can always change it back in settings. let me know what you think about this. 17 | - "download audio" button from image picker no longer stays on the screen after popup was closed. 18 | - clipboard button now shows up depending on your browser's support for it. 19 | - you can no longer manually hide the clipboard button, 'cause it's unnecessary. 20 | - small internal improvements such as separation of changelog version and title. 21 | - fair bit of internal clean up. 22 | 23 | if you want to help me implement covers for downloaded audios, [you can do it on github](https://github.com/imputnet/cobalt). -------------------------------------------------------------------------------- /web/changelogs/4.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "better tiktok image downloads" 3 | date: "Oct 27, 2022" 4 | --- 5 | here's what's up: 6 | - tiktok images are saved as .jpeg instead of .webp (finally, i know). 7 | - added support for image downloads from douyin. 8 | - fixed tiktok audio downloads from the image picker. 9 | - emoji in about button now changes on special occasions. be it halloween or christmas, cobalt will change just a tiny bit to fit in :D 10 | 11 | if you're not caught up with new stuff in cobalt 4.x yet, check out the previous changelog down below. there's a ton of stuff to like. -------------------------------------------------------------------------------- /web/changelogs/4.2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "optimized quality picking and 8k video support" 3 | date: "Nov 4, 2022" 4 | --- 5 | - this update fixes quality picking that was accidentally broken in 4.0 update. 6 | - you now can download videos in 8k from youtube. why would you that? no idea. but i'm more than happy to give you this option. 7 | - default video quality for downloads from pc is now 1440p, and 720p for phones. 8 | - default video format is now mp4 for everyone. 9 | - default audio format is now mp3 for everyone. 10 | 11 | you can always change new defaults back to whatever you prefer in settings. 12 | 13 | other changes: 14 | - added more clarity to quality picker description. 15 | - youtube video codecs are now right in the picker. 16 | - setup script is now easier to understand. -------------------------------------------------------------------------------- /web/changelogs/4.3.2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "twitter improvements & changelog overhaul" 3 | date: "Nov 15, 2022" 4 | --- 5 | - you can download explicit content from twitter. 6 | - direct video links from twitter are properly supported (video/1, video/2, etc.). 7 | - changelog history got support for banners. 8 | - changelog categories are not messy anymore. 9 | - cobalt version in changelogs is now highlighted. 10 | - changelog history got separators to make text easier to read. 11 | - changelog history can be collapsed after loading. 12 | - download button takes less time to change back to pressable state. 13 | 14 | if you're a developer and would like to play around with cobalt's api, then read more about it in older changelogs below! -------------------------------------------------------------------------------- /web/changelogs/4.4.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "over 1 million monthly requests. thank you." 3 | date: "Nov 20, 2022" 4 | banner: 5 | file: "onemillionr.webp" 6 | alt: "cobalt logo and a confetti emoji" 7 | --- 8 | this is a huge milestone for me, i cannot express enough how grateful i am for each and every one of you. 9 | thank you for using cobalt, and thank you for showing that people love the web that's friendly and bullshit-free. i'm hoping to never disappoint you in the future and keep up the good work. 10 | 11 | thank you &lt;3 12 | 13 | if you want to thank ME, check out the renovated donations tab, which now is also linked alongside bottom action buttons. -------------------------------------------------------------------------------- /web/changelogs/4.6.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "mute videos and proper soundcloud support" 3 | date: "Dec 17, 2022" 4 | banner: 5 | file: "shutup.webp" 6 | alt: "a cat yawning, with a crossed out loudspeaker icon next to it" 7 | --- 8 | i've been longing to implement both of these things, and here they finally are. 9 | 10 | service-related improvements: 11 | - you now can download videos with no audio! simply enable the "mute audio" option in settings > audio. 12 | - soundcloud module has been updated, and downloads should no longer break after some time. 13 | visual improvements: 14 | - moved some things around in settings popup, and added separators where separation is needed. 15 | - updated some texts in english and russian. 16 | - version and commit hash have been joined together, now they're a single unit. 17 | internal improvements: 18 | - updated api documentation to include isAudioMuted. 19 | - simplified the startup message. 20 | - created render elements for separator and explanation due to high duplication of them in the page. 21 | - fully deprecated GET method for API requests. 22 | - fixed some code quirks. 23 | here's how soundcloud downloads got fixed: 24 | 25 | previously, client_id was (stupidly) hardcoded. that means cobalt wasn't able to fetch song data if soundcloud web app got updated. 26 | now, cobalt tries to find the up-to-date client_id, caches it in memory, and checks if web app version has changed to update the id accordingly. you can see this change for yourself on github. -------------------------------------------------------------------------------- /web/changelogs/4.8.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "prettier than ever" 3 | date: "Jan 29, 2023" 4 | banner: 5 | file: "catmakeup.webp" 6 | alt: "a cat being brushed with a powder makeup brush" 7 | --- 8 | this version brings many visual improvements and a completely revamped "about" tab. 9 | 10 | what's new in "about" tab: 11 | - all information is now split into collapsible sections, making it easier to navigate. 12 | - added privacy policy to further prove that none of your data is collected. 13 | - added emoji to the page title to make it look consistent with other pages. 14 | - added mastodon account handle and link. 15 | - there are now short notes at the end of each section. 16 | - other changes that are too small to describe. just go check it out! 17 | 18 | visual improvements: 19 | - less wasted space: paddings and margins have been reduced and optimized for usability, consistency, and overall beauty. 20 | - all [links](https://youtu.be/dQw4w9WgXcQ) are now in italic. it's much easier to tell them apart from regular highlights. 21 | - error popup no longer looks broken and out of place. 22 | - download popup now has a proper close button, not something from 2.x era. 23 | - emoji are no longer selectable or draggable. 24 | - better scalability: desktop layout for home screen is shown if device viewport is wide enough to fit in three action buttons. 25 | - page shouldn't look broken on phones in landscape mode (i still highly recommend using cobalt in portrait mode). 26 | - removed bulletpoint padding. it was unnecessary. 27 | - updated some service names. 28 | 29 | as always, you can suggest features or report bugs on any platform listed in the "support" section of about tab. 30 | 31 | thank you for using cobalt. i hope you have a good day :) -------------------------------------------------------------------------------- /web/changelogs/5.3.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "better looks, better feel" 3 | date: "Apr 3, 2023" 4 | banner: 5 | file: "cattired.webp" 6 | alt: "a cat laying on a sofa face down, wiggling its tail" 7 | --- 8 | this update isn't as big as previous ones, but it still greatly enhances the cobalt experience. 9 | 10 | here's what's up: 11 | - new mode switcher! elegant and 100% clear. should no longer cause any confusion. let me know if you like it better this way :D 12 | - wide paste button on mobile is back, but now it's even closer to your finger. 13 | - removed the weird grey chin on changelog banners. 14 | - removed left-handed layout toggle since it is no longer needed. 15 | - fixed input area display in chromium 112+. 16 | - centered the main action box. 17 | - cleaned up css of main action box to get rid of tricks and ensure correct display on all devices. 18 | - fixed a bug that'd cause notifications dots to disappear when an unrelated checkbox was checked. 19 | 20 | hopefully from now on i'll focus on adding support for more services. 21 | thank you for using cobalt. stay cool :) -------------------------------------------------------------------------------- /web/changelogs/6.2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "all network issues have been fixed!" 3 | date: "June 27 2023" 4 | banner: 5 | file: "meowthhammer.webp" 6 | alt: "meowth plush holding a hammer in real life" 7 | --- 8 | hey! there have been some hiccups in cobalt's stability lately, i was going through finals while trying to scale up the infrastructure, and that didn't really work out, lol. 9 | BUT i'm happy to announce that i've optimized all nodes! there should no longer be any networking issues. 10 | 11 | enjoy stable experience while i work in background to make cobalt even better :) 12 | 13 | here's what's new in this update: 14 | - better button contrast in both themes. 15 | - button highlight in light theme now actually looks like a highlight. 16 | - removed ip gate for streamables and updated privacy policy to reflect this change. 17 | - streamable links now last for 20 seconds instead of 2 minutes. 18 | - cleaned up stream verification algorithm. now the same function doesn't run 4 times in a row. 19 | - removed deprecated way of hosting a cobalt instance. 20 | 21 | thank you for sticking with cobalt, and i hope you have a great day :D 22 | 23 | banner photo is by [@halftroller](https://twitter.com/halftroller) on twitter, thank you so much! -------------------------------------------------------------------------------- /web/changelogs/7.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "instagram, streamable, video metadata, and more!" 3 | date: "August 20, 2023" 4 | banner: 5 | file: "meowthproductions.webp" 6 | alt: "meowth roaring in a fancy circle, à la MGM studios intro" 7 | --- 8 | service improvements: 9 | - extended instagram support: high quality photos, videos, reels. everything should work without any issues, enjoy! :) 10 | - added support for streamable.com (thanks to [#179](https://github.com/imputnet/cobalt/pull/179)) 11 | - added video metadata to youtube videos. 12 | - fixed vk video downloads. 13 | - vxtwitter links are now supported. 14 | - fixed support for youtube audio dubs. 15 | 16 | ui improvements: 17 | - fixed picker popup: it's now scrollable in all cases and clickable areas don't overlap each other. 18 | 19 | backend improvements: 20 | - cobalt will now let you know if something goes wrong during video download instead of nuking the stream. 21 | - added support for cookies (thanks to [#177](https://github.com/imputnet/cobalt/pull/177)) 22 | - replaced got with undici (thanks to [#182](https://github.com/imputnet/cobalt/pull/182)). downloads should be slightly faster and clean of garbage in headers. 23 | 24 | internal improvements: 25 | - moved host overrides into its own module. 26 | - minor clean ups. 27 | 28 | even more cool stuff is coming in future updates! thank you for using cobalt :D -------------------------------------------------------------------------------- /web/changelogs/7.3.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "extended video length limit, metadata toggle, ui improvements, and more!" 3 | date: "September 6, 2023" 4 | banner: 5 | file: "meowthsnap.webp" 6 | alt: "cartoon meowth pointing paw dramatically and saying something" 7 | --- 8 | this update gives cobalt a sharp look in chromium browsers and makes it even more useful than before. check out the full changelog below! 9 | 10 | service improvements: 11 | - increased video length limit from 3 hours to 5 hours. feel free to download lectures you need :) 12 | - you can now disable file metadata in settings. 13 | - fixed a bug which previously caused some downloads to end up being 0 bytes. 14 | 15 | ui improvements: 16 | - fixed clickable area for urgent notice (text on top). 17 | - fixed blurry header in chrome. 18 | - fixed blurry tab bar in chrome. 19 | - fixed blurry switches in chrome. 20 | - fixed weirdly rounded corners in popups. 21 | - fixed 1px gap on edges of various elements in popup in chrome. 22 | - fixed overscrolling in other settings tab on ios. 23 | - fixed unexpected button highlight effect on phones. 24 | - removed outdated fixes for tiny screens. 25 | 26 | other improvements: 27 | - cobalt web & api start faster than before, additional preparation functions aren't unexpectedly run anymore. 28 | - cobalt is now available as a docker package. check it out on [github](https://github.com/imputnet/cobalt/pkgs/container/cobalt). 29 | 30 | thank you for being here. i hope you have a great day :D -------------------------------------------------------------------------------- /web/changelogs/7.5.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "support for twitch clips and rutube!" 3 | date: "September 16, 2023" 4 | banner: 5 | file: "twitchupdate.webp" 6 | alt: "meowth plush staring into the camera, laptop with generic purple service in the background" 7 | --- 8 | hey! this update (finally) adds support for twitch clips and rutube, among other smaller changes. 9 | 10 | service improvements: 11 | - added support for twitch clips. no vods, they're unnecessary. just clip whatever you want to download! 12 | - added support for rutube in case you ever wanted to download something russian. 13 | 14 | interface improvements: 15 | - added a note about cobalt not being affiliated with any supported services. 16 | - added a note about meta (the company) in russian. 17 | - better russian localization. will keep improving it to make it sound not so robotic over time. 18 | 19 | other improvements: 20 | - all official servers are now using the docker package. and so should you! 21 | - moved the load balancer to poland. requests should be slightly faster now. 22 | - minor codebase clean up. 23 | 24 | if you're confused about the new domain, read the older changelog! just scroll lower and press "expand". 25 | 26 | i hope you find this update useful and have a wonderful day :) 27 | 28 | btw, cobalt has a pretty active community server on discord. go to about > support & source code to join! -------------------------------------------------------------------------------- /web/changelogs/7.7.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "bugfixes and better downloads!" 3 | date: "December 2, 2023" 4 | banner: 5 | file: "meowthpolishegg.webp" 6 | alt: "meowth polishing a togepi egg" 7 | --- 8 | this update fixes various issues with supported services. no new features yet, but twitter fix is surely something good to have in the meantime! 9 | 10 | service improvements: 11 | - broken twitter videos are now automatically fixed by cobalt. 12 | - all vimeo videos and audios should now be possible to download. 13 | - vimeo: fixed short resolution displayed in "basic" and "pretty" filename styles. 14 | 15 | interface improvements: 16 | - streamables are now easier to save on ios. 17 | 18 | internal improvements: 19 | - port env variable is now not strictly necessary for cobalt to run. 20 | - minor clean up. 21 | 22 | changes since 7.6: 23 | - fix for an issue related to youtube dubs. 24 | - fixed a memory leak related to live renders. 25 | - handling all errors related to twitter downloads. 26 | - fixed support for reddit links in various languages. 27 | - added rich filenames support for twitch clips. 28 | - updated support and donation lists. 29 | 30 | stay tuned for future updates and have a great day :D -------------------------------------------------------------------------------- /web/changelogs/7.8.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "new years clean up! bug fixes and fresh look for the home page" 3 | date: "December 25, 2023" 4 | banner: 5 | file: "catroomba.webp" 6 | alt: "a cat riding a roomba vacuum" 7 | --- 8 | merry christmas and happy new year! this update fixes several (very annoying) bugs to help you enjoy your holidays better. 9 | 10 | you might have already noticed, but we've refreshed the home page on desktop and mobile! less space wasted, more pleasant to look at. let us know if you like it or not :D 11 | 12 | service improvements: 13 | - [#264](https://github.com/imputnet/cobalt/issues/264) anything that includes a period in the url should be possible to download (including instagram stories). 14 | - [#273](https://github.com/imputnet/cobalt/issues/273) soundcloud: falling back to mp3 instead of refusing to download the song at all. 15 | - [#275](https://github.com/imputnet/cobalt/issues/275) youtube: query parameters are parsed and handled correctly, all links should be supported, no matter where v query is located. 16 | - tlds are parsed and validated correctly (e.g. "pinterest.co.uk" works now). 17 | - fixvx.com links are now supported. 18 | 19 | interface improvements: 20 | - cleaner and more consistent home page layout. 21 | - cleaned up support section in "about". also includes a link to the status page. 22 | 23 | internal improvements: 24 | - urls, subdomains, and tlds are properly validated. 25 | - minor clean up. 26 | 27 | changes since 7.7: 28 | - made terms and ethics more descriptive. 29 | - fix only affected twitter videos. 30 | - fixed quick ⌘+V pasting on mac. 31 | - now catching even more youtube-related errors. 32 | 33 | this might not seem like a lot, but even smaller changes make a difference! 34 | 35 | enjoy this update and the rest of your day :D -------------------------------------------------------------------------------- /web/eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | ...tseslint.configs.recommended, 9 | ); 10 | -------------------------------------------------------------------------------- /web/i18n/en/a11y/dialog.json: -------------------------------------------------------------------------------- 1 | { 2 | "picker.item.photo": "photo thumbnail", 3 | "picker.item.video": "video thumbnail", 4 | "picker.item.gif": "gif thumbnail" 5 | } 6 | -------------------------------------------------------------------------------- /web/i18n/en/a11y/donate.json: -------------------------------------------------------------------------------- 1 | { 2 | "share.qr.expand": "qr code. press to expand.", 3 | "share.qr.collapse": "expanded qr code. press to collapse." 4 | } 5 | -------------------------------------------------------------------------------- /web/i18n/en/a11y/general.json: -------------------------------------------------------------------------------- 1 | { 2 | "back": "go back" 3 | } 4 | -------------------------------------------------------------------------------- /web/i18n/en/a11y/queue.json: -------------------------------------------------------------------------------- 1 | { 2 | "status.default": "processing queue", 3 | "status.completed": "processing queue. all tasks are completed.", 4 | "status.ongoing": "processing queue. ongoing tasks." 5 | } 6 | -------------------------------------------------------------------------------- /web/i18n/en/a11y/save.json: -------------------------------------------------------------------------------- 1 | { 2 | "link_area": "link input area", 3 | "link_area.turnstile": "link input area. checking if you're not a robot.", 4 | "clear_input": "clear input", 5 | "download": "download", 6 | "download.think": "processing the link...", 7 | "download.check": "verifying download...", 8 | "download.done": "downloading done", 9 | "download.error": "downloading error", 10 | 11 | "tutorial.shortcut.photos": "add photos shortcut", 12 | "tutorial.shortcut.files": "add files shortcut" 13 | } 14 | -------------------------------------------------------------------------------- /web/i18n/en/a11y/tabs.json: -------------------------------------------------------------------------------- 1 | { 2 | "tab_panel": "tabs panel" 3 | } 4 | -------------------------------------------------------------------------------- /web/i18n/en/about.json: -------------------------------------------------------------------------------- 1 | { 2 | "page.general": "what's cobalt?", 3 | "page.faq": "FAQ", 4 | 5 | "page.community": "community & support", 6 | 7 | "page.privacy": "privacy policy", 8 | "page.terms": "terms and ethics", 9 | "page.credits": "thanks & licenses", 10 | 11 | "heading.general": "general terms", 12 | "heading.licenses": "licenses", 13 | "heading.summary": "best way to save what you love", 14 | "heading.privacy_efficiency": "leading privacy & efficiency", 15 | "heading.community": "open community", 16 | "heading.local": "local processing", 17 | "heading.saving": "saving", 18 | "heading.encryption": "encryption", 19 | "heading.plausible": "anonymous traffic analytics", 20 | "heading.cloudflare": "web privacy & security", 21 | "heading.responsibility": "user responsibilities", 22 | "heading.abuse": "reporting abuse", 23 | "heading.motivation": "motivation", 24 | "heading.testers": "beta testers", 25 | "heading.partners": "partners", 26 | 27 | "support.github": "check out cobalt's source code, contribute changes, or report issues", 28 | "support.discord": "chat with the community and developers about cobalt or ask for help", 29 | "support.twitter": "follow cobalt's updates and development on your twitter timeline", 30 | "support.telegram": "stay up to date with latest cobalt updates via a telegram channel", 31 | "support.bluesky": "follow cobalt's updates and development on your bluesky feed", 32 | 33 | "support.description.issue": "if you want to report a bug or some other recurring issue, please do it on github.", 34 | "support.description.help": "use discord for any other questions. describe the issue properly in #cobalt-support or else no one will be able help you.", 35 | "support.description.best-effort": "all support is best effort and not guaranteed, a reply might take some time." 36 | } 37 | -------------------------------------------------------------------------------- /web/i18n/en/button.json: -------------------------------------------------------------------------------- 1 | { 2 | "gotit": "got it", 3 | "cancel": "cancel", 4 | "reset": "reset", 5 | "done": "done", 6 | "download.audio": "download audio", 7 | "download": "download", 8 | "share": "share", 9 | "copy": "copy", 10 | "copy.section": "copy the section link", 11 | "copied": "copied", 12 | "import": "import", 13 | "continue": "continue", 14 | "star": "star", 15 | "follow": "follow", 16 | "save": "save", 17 | "export": "export", 18 | "yes": "yes", 19 | "no": "no", 20 | "clear": "clear", 21 | "show_input": "show input", 22 | "hide_input": "hide input", 23 | "restore_input": "restore input", 24 | "clear_input": "clear input", 25 | "clear_cache": "clear cache", 26 | "remove": "remove", 27 | "retry": "retry", 28 | "delete": "delete" 29 | } 30 | -------------------------------------------------------------------------------- /web/i18n/en/error.json: -------------------------------------------------------------------------------- 1 | { 2 | "import.no_data": "there are no settings to load from this file. are you sure it's the right one?", 3 | "import.invalid": "this file doesn't have valid cobalt settings to import. are you sure it's the right one?", 4 | "import.unknown": "couldn't load data from the file. it may be corrupted or of wrong format. here's the error i got:\n\n{{ value }}", 5 | 6 | "tunnel.probe": "couldn't test this tunnel. your browser or network configuration may be blocking access to one of cobalt servers. are you sure you don't have any weird browser extensions?", 7 | 8 | "captcha_too_long": "cloudflare turnstile is taking too long to check if you're not a bot. try again, but if it takes way too long again, you can try: disabling weird browser extensions, changing networks, using a different browser, or checking your device for malware.", 9 | 10 | "pipeline.missing_response_data": "the processing instance didn't return required file info, so i can't create a local processing pipeline for you. try again in a few seconds and report the issue if it sticks!" 11 | } 12 | -------------------------------------------------------------------------------- /web/i18n/en/error/queue.json: -------------------------------------------------------------------------------- 1 | { 2 | "no_final_file": "no final file output", 3 | "worker_didnt_start": "couldn't start a processing worker", 4 | 5 | "fetch.crashed": "fetch worker crashed, see console for details", 6 | "fetch.bad_response": "couldn't access the file tunnel", 7 | "fetch.no_file_reader": "couldn't write a file to cache", 8 | "fetch.empty_tunnel": "file tunnel is empty, try again", 9 | "fetch.corrupted_file": "file wasn't downloaded fully, try again", 10 | 11 | "ffmpeg.probe_failed": "couldn't probe this file, it may be unsupported or corrupted", 12 | "ffmpeg.out_of_memory": "not enough available memory, can't continue", 13 | "ffmpeg.no_input_format": "the file's format isn't supported", 14 | "ffmpeg.no_input_type": "the file's type isn't supported", 15 | "ffmpeg.crashed": "ffmpeg worker crashed, see console for details", 16 | "ffmpeg.no_render": "ffmpeg render is empty, something very odd happened", 17 | "ffmpeg.no_args": "ffmpeg worker didn't get required arguments" 18 | } 19 | -------------------------------------------------------------------------------- /web/i18n/en/general.json: -------------------------------------------------------------------------------- 1 | { 2 | "cobalt": "cobalt", 3 | "meowbalt": "meowbalt", 4 | "beta": "beta", 5 | 6 | "embed.description": "cobalt lets you save what you love without ads, tracking, paywalls or other nonsense. just paste the link and you're ready to rock!" 7 | } 8 | -------------------------------------------------------------------------------- /web/i18n/en/notification.json: -------------------------------------------------------------------------------- 1 | { 2 | "update.title": "update is available!", 3 | "update.subtext": "press to reload" 4 | } 5 | -------------------------------------------------------------------------------- /web/i18n/en/queue.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "processing queue", 3 | "stub": "nothing here yet, just the two of us.\ntry downloading something!", 4 | 5 | "state.waiting": "queued", 6 | "state.retrying": "retrying", 7 | "state.starting": "starting", 8 | 9 | "state.starting.fetch": "starting downloading", 10 | "state.starting.remux": "starting remuxing", 11 | "state.starting.encode": "starting transcoding", 12 | 13 | "state.running.remux": "remuxing", 14 | "state.running.fetch": "downloading", 15 | "state.running.encode": "transcoding" 16 | } 17 | -------------------------------------------------------------------------------- /web/i18n/en/receiver.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "drag or select a file", 3 | "title.multiple": "drag or select files", 4 | "title.drop": "drop the file here!", 5 | "title.drop.multiple": "drop the files here!", 6 | "accept": "supported formats: {{ formats }}." 7 | } 8 | -------------------------------------------------------------------------------- /web/i18n/en/remux.json: -------------------------------------------------------------------------------- 1 | { 2 | "bullet.purpose.title": "what does remux do?", 3 | "bullet.purpose.description": "remux fixes any issues with the file container, such as missing time info. it helps increase compatibility with old software, such as vegas pro and windows media player.", 4 | "bullet.explainer.title": "how does it work?", 5 | "bullet.explainer.description": "remuxing takes existing codec data and copies it over to a new media container. it's lossless, media data doesn't get re-encoded.", 6 | "bullet.privacy.title": "on-device processing", 7 | "bullet.privacy.description": "cobalt remuxes files locally. files never leave your device, so processing is nearly instant." 8 | } 9 | -------------------------------------------------------------------------------- /web/i18n/en/save.json: -------------------------------------------------------------------------------- 1 | { 2 | "paste": "paste", 3 | "paste.long": "paste and download", 4 | "auto": "auto", 5 | "audio": "audio", 6 | "mute": "mute", 7 | "input.placeholder": "paste the link here", 8 | "terms.note.agreement": "by continuing, you agree to", 9 | "terms.note.link": "terms and ethics of use", 10 | "services.title": "supported services", 11 | "services.title_show": "show supported services", 12 | "services.title_hide": "hide supported services", 13 | "services.disclaimer": "cobalt is not affiliated with any of the services listed above.", 14 | 15 | "tutorial.title": "how to save on ios?", 16 | "tutorial.intro": "to save media conveniently on ios, you'll need to use a companion siri shortcut from the share sheet.", 17 | "tutorial.step.1": "add companion siri shortcuts:", 18 | "tutorial.step.2": "press the \"share\" button in cobalt's saving dialog.", 19 | "tutorial.step.3": "select the respective shortcut in the share sheet.", 20 | "tutorial.outro": "these shortcuts will work only from the cobalt app, sharing links from other apps will not work.", 21 | "tutorial.shortcut.photos": "to photos", 22 | "tutorial.shortcut.files": "to files", 23 | 24 | "label.community_instance": "community instance", 25 | 26 | "tooltip.captcha": "cloudflare turnstile is checking if you're not a bot, please wait!" 27 | } 28 | -------------------------------------------------------------------------------- /web/i18n/en/tabs.json: -------------------------------------------------------------------------------- 1 | { 2 | "save": "save", 3 | "settings": "settings", 4 | "updates": "updates", 5 | "donate": "donate", 6 | "about": "about", 7 | "remux": "remux" 8 | } 9 | -------------------------------------------------------------------------------- /web/i18n/en/updates.json: -------------------------------------------------------------------------------- 1 | { 2 | "button.next": "go to older changelog ({{ value }})", 3 | "button.previous": "go to newer changelog ({{ value }})" 4 | } 5 | -------------------------------------------------------------------------------- /web/i18n/languages.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": "english", 3 | "ru": "русский" 4 | } 5 | -------------------------------------------------------------------------------- /web/i18n/ru/a11y/general.json: -------------------------------------------------------------------------------- 1 | { 2 | "back": "назад" 3 | } 4 | -------------------------------------------------------------------------------- /web/i18n/ru/a11y/save.json: -------------------------------------------------------------------------------- 1 | { 2 | "link_area": "зона вставки ссылки", 3 | "clear_input": "clear input", 4 | "download": "скачать", 5 | "download.think": "обрабатываю ссылку...", 6 | "download.check": "проверяю загрузку...", 7 | "download.done": "загрузка завершена!", 8 | "download.error": "ошибка загрузки" 9 | } 10 | -------------------------------------------------------------------------------- /web/i18n/ru/a11y/tabs.json: -------------------------------------------------------------------------------- 1 | { 2 | "tab_panel": "панель вкладок" 3 | } 4 | -------------------------------------------------------------------------------- /web/i18n/ru/general.json: -------------------------------------------------------------------------------- 1 | { 2 | "cobalt": "кобальт", 3 | "meowbalt": "мяубальт", 4 | "beta": "бета", 5 | 6 | "embed.description": "сохраняй то, что любишь: без рекламы, трекеров и прочей чепухи. кобальт создан с любовью, а не с целью заработать." 7 | } 8 | -------------------------------------------------------------------------------- /web/i18n/ru/save.json: -------------------------------------------------------------------------------- 1 | { 2 | "paste": "вставить", 3 | "paste.long": "вставить и скачать", 4 | "auto": "авто", 5 | "audio": "аудио", 6 | "mute": "без звука", 7 | "input.placeholder": "вставь ссылку сюда", 8 | "terms.note.agreement": "продолжая, ты соглашаешься с", 9 | "terms.note.link": "условиями и этикой использования", 10 | "services.title": "поддерживаемые сервисы", 11 | "services.title_show": "показать поддерживаемые сервисы", 12 | "services.title_hide": "скрыть поддерживаемые сервисы", 13 | "services.disclaimer": "кобальт не аффилирован ни с одним из перечисленных выше сервисов.\n\nдеятельность meta platforms (владельца facebook и instagram) запрещена на территории РФ и признана экстремистской." 14 | } 15 | -------------------------------------------------------------------------------- /web/i18n/ru/tabs.json: -------------------------------------------------------------------------------- 1 | { 2 | "save": "скачать", 3 | "settings": "настройки", 4 | "updates": "новости", 5 | "donate": "донаты", 6 | "about": "инфа", 7 | "remux": "ремукс" 8 | } 9 | -------------------------------------------------------------------------------- /web/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // needed so that changelog files are appropriately 2 | // typed as svelte components 3 | declare module '*.md' { 4 | import type { SvelteComponentDev } from 'svelte/internal'; 5 | 6 | export default class Comp extends SvelteComponentDev { 7 | $$prop_def: {}; 8 | } 9 | export const metadata: Record; 10 | } 11 | 12 | // See https://kit.svelte.dev/docs/types#app 13 | // for information about these interfaces 14 | declare global { 15 | namespace App { 16 | // interface Error {} 17 | // interface Locals {} 18 | // interface PageData {} 19 | // interface PageState {} 20 | // interface Platform {} 21 | } 22 | } 23 | 24 | export {}; 25 | -------------------------------------------------------------------------------- /web/src/components/buttons/ActionButton.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | -------------------------------------------------------------------------------- /web/src/components/buttons/SettingsButton.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 38 | -------------------------------------------------------------------------------- /web/src/components/buttons/VerticalActionButton.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 21 | 22 | 45 | -------------------------------------------------------------------------------- /web/src/components/changelog/ChangelogEntryWrapper.svelte: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 21 | 22 | 23 | {@render children?.()} 24 | 25 | -------------------------------------------------------------------------------- /web/src/components/dialog/DialogBackdropClose.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 19 | -------------------------------------------------------------------------------- /web/src/components/dialog/DialogButton.svelte: -------------------------------------------------------------------------------- 1 | 26 | {#if button.link} 27 | 33 | {button.text} 34 | 35 | {:else} 36 | 48 | {/if} 49 | 71 | -------------------------------------------------------------------------------- /web/src/components/dialog/DialogButtons.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 26 | -------------------------------------------------------------------------------- /web/src/components/dialog/DialogContainer.svelte: -------------------------------------------------------------------------------- 1 | 38 | 39 | 40 | 41 | {}} /> 42 | 43 | -------------------------------------------------------------------------------- /web/src/components/dialog/NoScriptDialog.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | {#if !browser} 7 | 28 | {/if} 29 | 30 | 43 | -------------------------------------------------------------------------------- /web/src/components/donate/DonationOption.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 26 | 27 | 45 | -------------------------------------------------------------------------------- /web/src/components/icons/Clipboard.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /web/src/components/icons/Cobalt.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /web/src/components/icons/Imput.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /web/src/components/icons/Music.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /web/src/components/icons/Mute.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /web/src/components/icons/Sparkles.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /web/src/components/misc/AboutPageWrapper.svelte: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 |
8 | 9 |
10 | -------------------------------------------------------------------------------- /web/src/components/misc/BetaTesters.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 |
    21 | {#each credits as { name, url }} 22 |
  • 23 | {#if url} 24 | 25 | {name} 26 | 27 | {:else} 28 | {name} 29 | {/if} 30 |
  • 31 | {/each} 32 |
33 | -------------------------------------------------------------------------------- /web/src/components/misc/BulletExplain.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |
9 | 10 |
11 |
12 |
13 | {title} 14 |
15 |
16 | {description} 17 |
18 |
19 |
20 | 21 | 78 | -------------------------------------------------------------------------------- /web/src/components/misc/CopyIcon.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 |
12 | {#if regularIcon} 13 | 14 | {:else} 15 | 16 | {/if} 17 |
18 |
19 | 20 |
21 |
22 | 23 | 64 | -------------------------------------------------------------------------------- /web/src/components/misc/DropReceiver.svelte: -------------------------------------------------------------------------------- 1 | 35 | 36 |
dropHandler(ev)} 40 | ondragover={(ev) => dragOverHandler(ev)} 41 | ondragend={() => { 42 | draggedOver = false; 43 | }} 44 | ondragleave={() => { 45 | draggedOver = false; 46 | }} 47 | > 48 | {@render children?.()} 49 |
50 | -------------------------------------------------------------------------------- /web/src/components/misc/Meowbalt.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | (loaded = true)} 19 | src="/meowbalt/{emotion}.png" 20 | height="152" 21 | alt={$t("general.meowbalt")} 22 | aria-hidden="true" 23 | /> 24 | 25 | 50 | -------------------------------------------------------------------------------- /web/src/components/misc/OuterLink.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /web/src/components/misc/Placeholder.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | 9 |
10 | {`${pageName} page is not ready yet!`} 11 |
12 |
13 | 14 | 20 | -------------------------------------------------------------------------------- /web/src/components/misc/Toggle.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
7 |
8 | 9 | 48 | -------------------------------------------------------------------------------- /web/src/components/misc/Turnstile.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 | 47 | 52 | 53 | 54 |
55 |
56 |
57 | 58 | 65 | -------------------------------------------------------------------------------- /web/src/components/queue/ProcessingQueueStub.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | 8 | 9 | {$t("queue.stub", { 10 | value: $t("queue.stub"), 11 | })} 12 | 13 |
14 | 15 | 39 | -------------------------------------------------------------------------------- /web/src/components/queue/ProgressBar.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
15 | {#if percentage} 16 |
20 | {:else if pipelineResults[workerId]} 21 |
25 | {:else} 26 | 31 | {/if} 32 |
33 | 34 | 55 | -------------------------------------------------------------------------------- /web/src/components/save/buttons/ClearButton.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | 17 | 31 | -------------------------------------------------------------------------------- /web/src/components/settings/ClearStorageButton.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 | 43 | 44 | {$t("button.clear_cache")} 45 | 46 | -------------------------------------------------------------------------------- /web/src/components/settings/DataSettingsButton.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 33 | -------------------------------------------------------------------------------- /web/src/components/settings/ResetSettingsButton.svelte: -------------------------------------------------------------------------------- 1 | 33 | 34 | 35 | 36 | {$t("button.reset")} 37 | 38 | -------------------------------------------------------------------------------- /web/src/components/sidebar/CobaltLogo.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | 30 | -------------------------------------------------------------------------------- /web/src/components/subnav/PageNavSection.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | 16 | 48 | -------------------------------------------------------------------------------- /web/src/fonts/noto-mono-cobalt.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Noto Sans Mono"; 3 | font-style: normal; 4 | font-display: swap; 5 | font-weight: 400; 6 | src: url(/fonts/noto-mono-cobalt.woff2) format("woff2"); 7 | } 8 | -------------------------------------------------------------------------------- /web/src/lib/api/api-url.ts: -------------------------------------------------------------------------------- 1 | import env from "$lib/env"; 2 | import { get } from "svelte/store"; 3 | import settings from "$lib/state/settings"; 4 | 5 | export const currentApiURL = () => { 6 | const processingSettings = get(settings).processing; 7 | const customInstanceURL = processingSettings.customInstanceURL; 8 | 9 | if (processingSettings.enableCustomInstances && customInstanceURL.length > 0) { 10 | return new URL(customInstanceURL).origin; 11 | } 12 | 13 | return new URL(env.DEFAULT_API!).origin; 14 | } 15 | -------------------------------------------------------------------------------- /web/src/lib/api/safety-warning.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | import { t } from "$lib/i18n/translations"; 3 | import settings, { updateSetting } from "$lib/state/settings"; 4 | 5 | import { createDialog } from "$lib/state/dialogs"; 6 | 7 | export const customInstanceWarning = async () => { 8 | if (get(settings).processing.seenCustomWarning) { 9 | return; 10 | } 11 | 12 | let _actions: { 13 | resolve: () => void; 14 | reject: () => void; 15 | }; 16 | 17 | const promise = new Promise( 18 | (resolve, reject) => (_actions = { resolve, reject }) 19 | ).catch(() => { 20 | return {} 21 | }); 22 | 23 | createDialog({ 24 | id: "security-api-custom", 25 | type: "small", 26 | icon: "warn-red", 27 | title: get(t)("dialog.safety.title"), 28 | bodyText: get(t)("dialog.safety.custom_instance.body"), 29 | leftAligned: true, 30 | buttons: [ 31 | { 32 | text: get(t)("button.cancel"), 33 | main: false, 34 | action: () => { 35 | _actions.reject(); 36 | }, 37 | }, 38 | { 39 | text: get(t)("button.continue"), 40 | color: "red", 41 | main: true, 42 | timeout: 5000, 43 | action: () => { 44 | _actions.resolve(); 45 | updateSetting({ 46 | processing: { 47 | seenCustomWarning: true, 48 | }, 49 | }) 50 | }, 51 | }, 52 | ], 53 | }) 54 | 55 | await promise; 56 | } 57 | -------------------------------------------------------------------------------- /web/src/lib/api/session.ts: -------------------------------------------------------------------------------- 1 | import turnstile from "$lib/api/turnstile"; 2 | import { currentApiURL } from "$lib/api/api-url"; 3 | 4 | import type { CobaltSession, CobaltErrorResponse, CobaltSessionResponse } from "$lib/types/api"; 5 | 6 | let cache: CobaltSession | undefined; 7 | 8 | export const requestSession = async () => { 9 | const apiEndpoint = `${currentApiURL()}/session`; 10 | 11 | let requestHeaders = {}; 12 | 13 | const turnstileResponse = turnstile.getResponse(); 14 | if (turnstileResponse) { 15 | requestHeaders = { 16 | "cf-turnstile-response": turnstileResponse 17 | }; 18 | } 19 | 20 | const response: CobaltSessionResponse = await fetch(apiEndpoint, { 21 | method: "POST", 22 | redirect: "manual", 23 | signal: AbortSignal.timeout(10000), 24 | headers: requestHeaders, 25 | }) 26 | .then(r => r.json()) 27 | .catch((e) => { 28 | if (e?.message?.includes("timed out")) { 29 | return { 30 | status: "error", 31 | error: { 32 | code: "error.api.timed_out" 33 | } 34 | } as CobaltErrorResponse 35 | } 36 | }); 37 | 38 | turnstile.reset(); 39 | 40 | return response; 41 | } 42 | 43 | export const getSession = async () => { 44 | const currentTime = () => Math.floor(new Date().getTime() / 1000); 45 | 46 | if (cache?.token && cache?.exp - 2 > currentTime()) { 47 | return cache; 48 | } 49 | 50 | const newSession = await requestSession(); 51 | 52 | if (!newSession) return { 53 | status: "error", 54 | error: { 55 | code: "error.api.unreachable" 56 | } 57 | } as CobaltErrorResponse 58 | 59 | if (!("status" in newSession)) { 60 | newSession.exp = currentTime() + newSession.exp; 61 | cache = newSession; 62 | } 63 | return newSession; 64 | } 65 | 66 | export const resetSession = () => cache = undefined; 67 | -------------------------------------------------------------------------------- /web/src/lib/api/turnstile.ts: -------------------------------------------------------------------------------- 1 | import { turnstileSolved } from "$lib/state/turnstile"; 2 | 3 | const getResponse = () => { 4 | const turnstileElement = document.getElementById("turnstile-widget"); 5 | 6 | if (turnstileElement) { 7 | return window?.turnstile?.getResponse(turnstileElement); 8 | } 9 | 10 | return null; 11 | } 12 | 13 | const reset = () => { 14 | const turnstileElement = document.getElementById("turnstile-widget"); 15 | 16 | if (turnstileElement) { 17 | turnstileSolved.set(false); 18 | return window?.turnstile?.reset(turnstileElement); 19 | } 20 | 21 | return null; 22 | } 23 | 24 | export default { 25 | getResponse, 26 | reset, 27 | } 28 | -------------------------------------------------------------------------------- /web/src/lib/changelogs.ts: -------------------------------------------------------------------------------- 1 | import { compareVersions } from 'compare-versions'; 2 | 3 | export function getVersionFromPath(path: string) { 4 | return path.split('/').pop()?.split('.md').shift()!; 5 | } 6 | 7 | export function getAllChangelogs() { 8 | const changelogImports = import.meta.glob("/changelogs/*.md"); 9 | 10 | const sortedVersions = Object.keys(changelogImports) 11 | .map(path => [path, getVersionFromPath(path)]) 12 | .sort(([, a], [, b]) => compareVersions(a, b)); 13 | 14 | const sortedChangelogs = sortedVersions.reduce( 15 | (obj, [path, version]) => ({ 16 | [version]: changelogImports[path], 17 | ...obj 18 | }), {} as typeof changelogImports 19 | ); 20 | 21 | return sortedChangelogs; 22 | } 23 | -------------------------------------------------------------------------------- /web/src/lib/clipboard.ts: -------------------------------------------------------------------------------- 1 | const allowedLinkTypes = new Set(["text/plain", "text/uri-list"]); 2 | 3 | export const pasteLinkFromClipboard = async () => { 4 | const clipboard = await navigator.clipboard.read(); 5 | 6 | if (clipboard?.length) { 7 | const clipboardItem = clipboard[0]; 8 | for (const type of clipboardItem.types) { 9 | if (allowedLinkTypes.has(type)) { 10 | const blob = await clipboardItem.getType(type); 11 | const blobText = await blob.text(); 12 | 13 | return blobText; 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /web/src/lib/haptics.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | import { device } from "$lib/device"; 3 | import settings from "$lib/state/settings"; 4 | 5 | const canUseHaptics = () => { 6 | return device.supports.haptics && !get(settings).accessibility.disableHaptics; 7 | } 8 | 9 | export const hapticSwitch = () => { 10 | if (!canUseHaptics()) return; 11 | 12 | try { 13 | const label = document.createElement("label"); 14 | label.ariaHidden = "true"; 15 | label.style.display = "none"; 16 | 17 | const input = document.createElement("input"); 18 | input.type = "checkbox"; 19 | input.setAttribute("switch", ""); 20 | label.appendChild(input); 21 | 22 | document.head.appendChild(label); 23 | label.click(); 24 | document.head.removeChild(label); 25 | } catch { 26 | // ignore 27 | } 28 | } 29 | 30 | export const hapticConfirm = () => { 31 | if (!canUseHaptics()) return; 32 | 33 | hapticSwitch(); 34 | setTimeout(() => hapticSwitch(), 120); 35 | } 36 | 37 | export const hapticError = () => { 38 | if (!canUseHaptics()) return; 39 | 40 | hapticSwitch(); 41 | setTimeout(() => hapticSwitch(), 120); 42 | setTimeout(() => hapticSwitch(), 240); 43 | } 44 | -------------------------------------------------------------------------------- /web/src/lib/i18n/locale.ts: -------------------------------------------------------------------------------- 1 | import { derived } from 'svelte/store'; 2 | 3 | import languages from '$i18n/languages.json'; 4 | 5 | import settings from '$lib/state/settings'; 6 | import { device } from '$lib/device'; 7 | import { INTERNAL_locale, defaultLocale } from '$lib/i18n/translations'; 8 | 9 | const isValid = (lang: string) => ( 10 | Object.keys(languages).includes(lang) 11 | ); 12 | 13 | export default derived( 14 | settings, 15 | ($settings) => { 16 | let currentLocale = defaultLocale; 17 | 18 | if ($settings.appearance.autoLanguage) { 19 | if (isValid(device.prefers.language)) { 20 | currentLocale = device.prefers.language; 21 | } 22 | } else { 23 | if (isValid($settings.appearance.language)) { 24 | currentLocale = $settings.appearance.language; 25 | } 26 | } 27 | 28 | INTERNAL_locale.set(currentLocale); 29 | return currentLocale; 30 | } 31 | ); 32 | -------------------------------------------------------------------------------- /web/src/lib/i18n/translations.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'sveltekit-i18n'; 2 | 3 | import type { Config } from 'sveltekit-i18n'; 4 | import type { 5 | GenericImport, 6 | StructuredLocfileInfo, 7 | LocalizationContent 8 | } from '$lib/types/i18n'; 9 | 10 | import _languages from '$i18n/languages.json'; 11 | 12 | const locFiles = import.meta.glob('$i18n/*/**/*.json'); 13 | const parsedLocfiles: StructuredLocfileInfo = {}; 14 | 15 | for (const [path, loader] of Object.entries(locFiles)) { 16 | const [, , lang, ...keyComponents] = path.split('/'); 17 | const key = keyComponents.map(k => k.replace('.json', '')).join('.'); 18 | parsedLocfiles[lang] = { 19 | ...parsedLocfiles[lang], 20 | [key]: loader as GenericImport 21 | }; 22 | } 23 | 24 | const defaultLocale = 'en'; 25 | const languages: Record = _languages; 26 | 27 | const config: Config<{ 28 | value?: string; 29 | formats?: string; 30 | limit?: number; 31 | service?: string; 32 | }> = { 33 | fallbackLocale: defaultLocale, 34 | translations: Object.keys(parsedLocfiles).reduce((obj, lang) => { 35 | languages[lang] ??= `${lang} (missing name)`; 36 | 37 | return { 38 | ...obj, 39 | [lang]: { languages } 40 | } 41 | }, {}), 42 | loaders: Object.entries(parsedLocfiles).map(([lang, keys]) => { 43 | return Object.entries(keys).map(([key, importer]) => { 44 | return { 45 | locale: lang, 46 | key, 47 | loader: () => importer().then( 48 | l => l.default as LocalizationContent 49 | ) 50 | } 51 | }); 52 | }).flat() 53 | }; 54 | 55 | export { defaultLocale }; 56 | export const { 57 | t, loading, locales, locale: INTERNAL_locale, translations, 58 | loadTranslations, addTranslations, setLocale, setRoute 59 | } = new i18n(config); 60 | -------------------------------------------------------------------------------- /web/src/lib/polyfills.ts: -------------------------------------------------------------------------------- 1 | import "./polyfills/user-activation"; 2 | import "./polyfills/abortsignal-timeout"; 3 | -------------------------------------------------------------------------------- /web/src/lib/polyfills/abortsignal-timeout.ts: -------------------------------------------------------------------------------- 1 | import { browser } from "$app/environment"; 2 | 3 | if (browser && 'AbortSignal' in window && !window.AbortSignal.timeout) { 4 | window.AbortSignal.timeout = (milliseconds: number) => { 5 | const controller = new AbortController(); 6 | setTimeout(() => controller.abort("timed out"), milliseconds); 7 | 8 | return controller.signal; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /web/src/lib/settings/defaults.ts: -------------------------------------------------------------------------------- 1 | import { device } from "$lib/device"; 2 | import { defaultLocale } from "$lib/i18n/translations"; 3 | import type { CobaltSettings } from "$lib/types/settings"; 4 | 5 | const defaultSettings: CobaltSettings = { 6 | schemaVersion: 5, 7 | advanced: { 8 | debug: false, 9 | useWebCodecs: false, 10 | }, 11 | appearance: { 12 | theme: "auto", 13 | language: defaultLocale, 14 | autoLanguage: true, 15 | hideRemuxTab: false, 16 | }, 17 | accessibility: { 18 | reduceMotion: false, 19 | reduceTransparency: false, 20 | disableHaptics: false, 21 | dontAutoOpenQueue: false, 22 | }, 23 | save: { 24 | alwaysProxy: false, 25 | localProcessing: device.supports.defaultLocalProcessing || false, 26 | audioBitrate: "128", 27 | audioFormat: "mp3", 28 | disableMetadata: false, 29 | downloadMode: "auto", 30 | filenameStyle: "basic", 31 | savingMethod: "download", 32 | allowH265: false, 33 | tiktokFullAudio: false, 34 | convertGif: true, 35 | videoQuality: "1080", 36 | youtubeVideoCodec: "h264", 37 | youtubeDubLang: "original", 38 | youtubeHLS: false, 39 | youtubeBetterAudio: false, 40 | }, 41 | privacy: { 42 | disableAnalytics: false, 43 | }, 44 | processing: { 45 | customInstanceURL: "", 46 | customApiKey: "", 47 | enableCustomInstances: false, 48 | enableCustomApiKey: false, 49 | seenCustomWarning: false, 50 | } 51 | } 52 | 53 | export default defaultSettings; 54 | -------------------------------------------------------------------------------- /web/src/lib/settings/lazy-get.ts: -------------------------------------------------------------------------------- 1 | import defaults from "$lib/settings/defaults"; 2 | import type { CobaltSettings } from "$lib/types/settings"; 3 | 4 | export default function lazySettingGetter(settings: CobaltSettings) { 5 | // Returns the setting value only if it differs from the default. 6 | return < 7 | Context extends Exclude, 8 | Id extends keyof CobaltSettings[Context] 9 | >(context: Context, key: Id) => { 10 | if (defaults[context][key] !== settings[context][key]) { 11 | return settings[context][key]; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /web/src/lib/state/dialogs.ts: -------------------------------------------------------------------------------- 1 | import { readable, type Updater } from "svelte/store"; 2 | import type { DialogInfo } from "$lib/types/dialog"; 3 | 4 | let update: (_: Updater) => void; 5 | 6 | export default readable( 7 | [], 8 | (_, _update) => { update = _update } 9 | ); 10 | 11 | export function createDialog(newData: DialogInfo) { 12 | update((popups) => { 13 | popups.push(newData); 14 | return popups; 15 | }); 16 | } 17 | 18 | export function killDialog() { 19 | update((popups) => { 20 | popups.pop() 21 | return popups; 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /web/src/lib/state/omnibox.ts: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | import type { CobaltDownloadButtonState } from "$lib/types/omnibox"; 3 | 4 | export const link = writable(""); 5 | export const downloadButtonState = writable("idle"); 6 | -------------------------------------------------------------------------------- /web/src/lib/state/queue-visibility.ts: -------------------------------------------------------------------------------- 1 | import settings from "$lib/state/settings"; 2 | import { get, writable } from "svelte/store"; 3 | 4 | export const queueVisible = writable(false); 5 | 6 | export const openQueuePopover = () => { 7 | const visible = get(queueVisible); 8 | if (!visible && !get(settings).accessibility.dontAutoOpenQueue) { 9 | return queueVisible.update(v => !v); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /web/src/lib/state/server-info.ts: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | import * as ServerInfo from "$lib/api/server-info"; 3 | 4 | export default writable(); 5 | -------------------------------------------------------------------------------- /web/src/lib/state/task-manager/current-tasks.ts: -------------------------------------------------------------------------------- 1 | import { readonly, writable } from "svelte/store"; 2 | 3 | import type { CobaltWorkerProgress } from "$lib/types/workers"; 4 | import type { CobaltCurrentTasks, CobaltCurrentTaskItem } from "$lib/types/task-manager"; 5 | 6 | const currentTasks_ = writable({}); 7 | export const currentTasks = readonly(currentTasks_); 8 | 9 | export function addWorkerToQueue(workerId: string, item: CobaltCurrentTaskItem) { 10 | currentTasks_.update(tasks => { 11 | tasks[workerId] = item; 12 | return tasks; 13 | }); 14 | } 15 | 16 | export function removeWorkerFromQueue(id: string) { 17 | currentTasks_.update(tasks => { 18 | delete tasks[id]; 19 | return tasks; 20 | }); 21 | } 22 | 23 | export function updateWorkerProgress(workerId: string, progress: CobaltWorkerProgress) { 24 | currentTasks_.update(allTasks => { 25 | allTasks[workerId].progress = progress; 26 | return allTasks; 27 | }); 28 | } 29 | 30 | export function clearCurrentTasks() { 31 | currentTasks_.set({}); 32 | } 33 | -------------------------------------------------------------------------------- /web/src/lib/state/theme.ts: -------------------------------------------------------------------------------- 1 | import { readable, derived, type Readable } from 'svelte/store'; 2 | import { browser } from '$app/environment'; 3 | 4 | import settings from '$lib/state/settings'; 5 | import { themeOptions } from '$lib/types/settings'; 6 | 7 | type Theme = typeof themeOptions[number]; 8 | 9 | let set: (_: Theme) => void; 10 | 11 | const browserPreference = () => { 12 | if (!browser || window.matchMedia('(prefers-color-scheme: light)').matches) { 13 | return 'light'; 14 | } 15 | 16 | return 'dark' 17 | } 18 | 19 | const browserPreferenceReadable = readable( 20 | browserPreference(), 21 | _set => { set = _set } 22 | ) 23 | 24 | if (browser) { 25 | const matchMedia = window.matchMedia('(prefers-color-scheme: dark)'); 26 | 27 | if (matchMedia.addEventListener) { 28 | matchMedia.addEventListener('change', () => set(browserPreference())); 29 | } 30 | } 31 | 32 | export default derived( 33 | [settings, browserPreferenceReadable], 34 | ([$settings, $browserPref]) => { 35 | if ($settings.appearance.theme !== 'auto') { 36 | return $settings.appearance.theme; 37 | } 38 | 39 | return $browserPref; 40 | }, 41 | browserPreference() 42 | ) as Readable> 43 | 44 | export const statusBarColors = { 45 | mobile: { 46 | dark: "#000000", 47 | light: "#ffffff" 48 | }, 49 | desktop: { 50 | dark: "#131313", 51 | light: "#f4f4f4" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /web/src/lib/state/turnstile.ts: -------------------------------------------------------------------------------- 1 | import settings from "$lib/state/settings"; 2 | import cachedInfo from "$lib/state/server-info"; 3 | import { derived, writable } from "svelte/store"; 4 | 5 | export const turnstileSolved = writable(false); 6 | export const turnstileCreated = writable(false); 7 | 8 | export const turnstileEnabled = derived( 9 | [settings, cachedInfo], 10 | ([$settings, $cachedInfo]) => { 11 | return !!$cachedInfo?.info?.cobalt?.turnstileSitekey && 12 | !( 13 | $settings.processing.enableCustomApiKey && 14 | $settings.processing.customApiKey.length > 0 15 | ) 16 | } 17 | ) 18 | -------------------------------------------------------------------------------- /web/src/lib/storage/index.ts: -------------------------------------------------------------------------------- 1 | import type { AbstractStorage } from "./storage"; 2 | import { MemoryStorage } from "./memory"; 3 | import { OPFSStorage } from "./opfs"; 4 | 5 | export async function init(expectedSize?: number): Promise { 6 | if (await OPFSStorage.isAvailable()) { 7 | return OPFSStorage.init(); 8 | } 9 | 10 | if (await MemoryStorage.isAvailable()) { 11 | return MemoryStorage.init(expectedSize || 0); 12 | } 13 | 14 | throw "no storage method is available"; 15 | } 16 | 17 | export function retype(file: File, type: string) { 18 | return new File([ file ], file.name, { type }); 19 | } 20 | -------------------------------------------------------------------------------- /web/src/lib/storage/storage.ts: -------------------------------------------------------------------------------- 1 | export abstract class AbstractStorage { 2 | static init(_expected_size: number): Promise { 3 | throw "init() call on abstract implementation"; 4 | } 5 | 6 | static async isAvailable(): Promise { 7 | return false; 8 | } 9 | 10 | abstract res(): Promise; 11 | abstract write(data: Uint8Array | Int8Array, offset: number): Promise; 12 | abstract destroy(): Promise; 13 | }; 14 | -------------------------------------------------------------------------------- /web/src/lib/subnav.ts: -------------------------------------------------------------------------------- 1 | import { browser } from "$app/environment"; 2 | 3 | const defaultNavPage = (page: "settings" | "about") => { 4 | if (browser && window.innerWidth <= 750) { 5 | return `/${page}`; 6 | } 7 | 8 | switch (page) { 9 | case "settings": 10 | return "/settings/appearance"; 11 | case "about": 12 | return "/about/general"; 13 | } 14 | } 15 | 16 | export { defaultNavPage }; 17 | -------------------------------------------------------------------------------- /web/src/lib/task-manager/runners/fetch.ts: -------------------------------------------------------------------------------- 1 | import FetchWorker from "$lib/task-manager/workers/fetch?worker"; 2 | 3 | import { killWorker } from "$lib/task-manager/run-worker"; 4 | import { updateWorkerProgress } from "$lib/state/task-manager/current-tasks"; 5 | import { pipelineTaskDone, itemError, queue } from "$lib/state/task-manager/queue"; 6 | 7 | import type { CobaltQueue, UUID } from "$lib/types/queue"; 8 | 9 | export const runFetchWorker = async (workerId: UUID, parentId: UUID, url: string) => { 10 | const worker = new FetchWorker(); 11 | 12 | const unsubscribe = queue.subscribe((queue: CobaltQueue) => { 13 | if (!queue[parentId]) { 14 | killWorker(worker, unsubscribe); 15 | } 16 | }); 17 | 18 | worker.postMessage({ 19 | cobaltFetchWorker: { 20 | url 21 | } 22 | }); 23 | 24 | worker.onmessage = (event) => { 25 | const eventData = event.data.cobaltFetchWorker; 26 | if (!eventData) return; 27 | 28 | if (eventData.progress) { 29 | updateWorkerProgress(workerId, { 30 | percentage: eventData.progress, 31 | size: eventData.size, 32 | }) 33 | } 34 | 35 | if (eventData.result) { 36 | killWorker(worker, unsubscribe); 37 | return pipelineTaskDone( 38 | parentId, 39 | workerId, 40 | eventData.result, 41 | ); 42 | } 43 | 44 | if (eventData.error) { 45 | killWorker(worker, unsubscribe); 46 | return itemError(parentId, workerId, eventData.error); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /web/src/lib/types/changelogs.ts: -------------------------------------------------------------------------------- 1 | export interface ChangelogMetadata { 2 | title: string, 3 | date: string, 4 | banner?: { 5 | file: string, 6 | alt: string 7 | } 8 | }; 9 | 10 | export interface MarkdownMetadata { 11 | metadata: ChangelogMetadata 12 | }; 13 | 14 | export type ChangelogImport = { 15 | default: ConstructorOfATypedSvelteComponent, 16 | metadata: ChangelogMetadata 17 | }; -------------------------------------------------------------------------------- /web/src/lib/types/dialog.ts: -------------------------------------------------------------------------------- 1 | import type { CobaltFileUrlType } from "$lib/types/api"; 2 | import type { MeowbaltEmotions } from "$lib/types/meowbalt"; 3 | 4 | export type DialogButton = { 5 | text: string, 6 | color?: "red", 7 | main: boolean, 8 | timeout?: number, // milliseconds 9 | action: () => unknown | Promise, 10 | link?: string 11 | } 12 | 13 | export type SmallDialogIcons = "warn-red"; 14 | 15 | export type DialogPickerItem = { 16 | type?: 'photo' | 'video' | 'gif', 17 | url: string, 18 | thumb?: string, 19 | } 20 | 21 | type Dialog = { 22 | id: string, 23 | dismissable?: boolean, 24 | }; 25 | 26 | type SmallDialog = Dialog & { 27 | type: "small", 28 | meowbalt?: MeowbaltEmotions, 29 | icon?: SmallDialogIcons, 30 | title?: string, 31 | bodyText?: string, 32 | bodySubText?: string, 33 | buttons?: DialogButton[], 34 | leftAligned?: boolean, 35 | }; 36 | 37 | type PickerDialog = Dialog & { 38 | type: "picker", 39 | items?: DialogPickerItem[], 40 | buttons?: DialogButton[], 41 | }; 42 | 43 | type SavingDialog = Dialog & { 44 | type: "saving", 45 | bodyText?: string, 46 | url?: string, 47 | file?: File, 48 | urlType?: CobaltFileUrlType, 49 | }; 50 | 51 | export type DialogInfo = SmallDialog | PickerDialog | SavingDialog; 52 | -------------------------------------------------------------------------------- /web/src/lib/types/generic.ts: -------------------------------------------------------------------------------- 1 | // more readable version of recursive partial taken from stackoverflow: 2 | // https://stackoverflow.com/a/51365037 3 | export type RecursivePartial = { 4 | [Key in keyof Type]?: 5 | Type[Key] extends (infer ElementType)[] ? RecursivePartial[] : 6 | Type[Key] extends object | undefined ? RecursivePartial : 7 | Type[Key]; 8 | }; 9 | 10 | export type DefaultImport = () => Promise<{ default: T }>; 11 | export type Optional = T | undefined; 12 | export type Writeable = { -readonly [P in keyof T]: T[P] }; 13 | -------------------------------------------------------------------------------- /web/src/lib/types/i18n.ts: -------------------------------------------------------------------------------- 1 | import type { DefaultImport } from '$lib/types/generic'; 2 | 3 | type LanguageCode = string; 4 | type KeyPath = string; 5 | 6 | export type GenericImport = DefaultImport; 7 | export type LocalizationContent = Record; 8 | export type StructuredLocfileInfo = Record< 9 | LanguageCode, 10 | Record 11 | >; 12 | -------------------------------------------------------------------------------- /web/src/lib/types/libav.ts: -------------------------------------------------------------------------------- 1 | export type FileInfo = { 2 | type?: string, 3 | format?: string, 4 | } 5 | 6 | export type RenderParams = { 7 | files: File[], 8 | output: FileInfo, 9 | args: string[], 10 | } 11 | 12 | export type FFmpegProgressStatus = "continue" | "end" | "unknown"; 13 | export type FFmpegProgressEvent = { 14 | status: FFmpegProgressStatus, 15 | frame?: number, 16 | fps?: number, 17 | total_size?: number, 18 | dup_frames?: number, 19 | drop_frames?: number, 20 | speed?: number, 21 | out_time_sec?: number, 22 | } 23 | 24 | export type FFmpegProgressCallback = (info: FFmpegProgressEvent) => void; 25 | -------------------------------------------------------------------------------- /web/src/lib/types/meowbalt.ts: -------------------------------------------------------------------------------- 1 | export type MeowbaltEmotions = "smile" | "error" | "question" | "think" | "fast"; -------------------------------------------------------------------------------- /web/src/lib/types/omnibox.ts: -------------------------------------------------------------------------------- 1 | export type CobaltDownloadButtonState = "idle" | "think" | "check" | "done" | "error"; 2 | -------------------------------------------------------------------------------- /web/src/lib/types/queue.ts: -------------------------------------------------------------------------------- 1 | import type { CobaltSaveRequestBody } from "$lib/types/api"; 2 | import type { CobaltPipelineItem, CobaltPipelineResultFileType } from "$lib/types/workers"; 3 | 4 | export type UUID = string; 5 | 6 | type CobaltQueueBaseItem = { 7 | id: UUID, 8 | pipeline: CobaltPipelineItem[], 9 | canRetry?: boolean, 10 | originalRequest?: CobaltSaveRequestBody, 11 | filename: string, 12 | mimeType?: string, 13 | mediaType: CobaltPipelineResultFileType, 14 | }; 15 | 16 | type CobaltQueueItemWaiting = CobaltQueueBaseItem & { 17 | state: "waiting", 18 | }; 19 | 20 | export type CobaltQueueItemRunning = CobaltQueueBaseItem & { 21 | state: "running", 22 | pipelineResults: Record, 23 | }; 24 | 25 | type CobaltQueueItemDone = CobaltQueueBaseItem & { 26 | state: "done", 27 | resultFile: File, 28 | }; 29 | 30 | type CobaltQueueItemError = CobaltQueueBaseItem & { 31 | state: "error", 32 | errorCode: string, 33 | }; 34 | 35 | export type CobaltQueueItem = CobaltQueueItemWaiting 36 | | CobaltQueueItemRunning 37 | | CobaltQueueItemDone 38 | | CobaltQueueItemError; 39 | 40 | export type CobaltQueue = { 41 | [id: UUID]: CobaltQueueItem, 42 | }; 43 | -------------------------------------------------------------------------------- /web/src/lib/types/settings.ts: -------------------------------------------------------------------------------- 1 | import type { RecursivePartial } from "$lib/types/generic"; 2 | import type { CobaltSettingsV2 } from "$lib/types/settings/v2"; 3 | import type { CobaltSettingsV3 } from "$lib/types/settings/v3"; 4 | import type { CobaltSettingsV4 } from "$lib/types/settings/v4"; 5 | import type { CobaltSettingsV5 } from "$lib/types/settings/v5"; 6 | 7 | export * from "$lib/types/settings/v2"; 8 | export * from "$lib/types/settings/v3"; 9 | export * from "$lib/types/settings/v4"; 10 | export * from "$lib/types/settings/v5"; 11 | 12 | export type CobaltSettings = CobaltSettingsV5; 13 | 14 | export type AnyCobaltSettings = CobaltSettingsV4 | CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings; 15 | 16 | export type PartialSettings = RecursivePartial; 17 | 18 | export type AllPartialSettingsWithSchema = RecursivePartial & { schemaVersion: number }; 19 | 20 | export type DownloadModeOption = CobaltSettings['save']['downloadMode']; 21 | -------------------------------------------------------------------------------- /web/src/lib/types/settings/v3.ts: -------------------------------------------------------------------------------- 1 | import type { YoutubeLang } from "$lib/settings/youtube-lang"; 2 | import { type CobaltSettingsV2 } from "$lib/types/settings/v2"; 3 | 4 | export type CobaltSettingsV3 = Omit & { 5 | schemaVersion: 3, 6 | save: Omit & { 7 | youtubeDubLang: YoutubeLang; 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /web/src/lib/types/settings/v4.ts: -------------------------------------------------------------------------------- 1 | import { type CobaltSettingsV3 } from "$lib/types/settings/v3"; 2 | 3 | export type CobaltSettingsV4 = Omit & { 4 | schemaVersion: 4, 5 | processing: Omit & { 6 | customApiKey: string; 7 | enableCustomApiKey: boolean; 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /web/src/lib/types/settings/v5.ts: -------------------------------------------------------------------------------- 1 | import { type CobaltSettingsV4 } from "$lib/types/settings/v4"; 2 | 3 | export type CobaltSettingsV5 = Omit & { 4 | schemaVersion: 5, 5 | appearance: Omit & { 6 | hideRemuxTab: boolean, 7 | }, 8 | accessibility: { 9 | reduceMotion: boolean; 10 | reduceTransparency: boolean; 11 | disableHaptics: boolean; 12 | dontAutoOpenQueue: boolean; 13 | }, 14 | advanced: CobaltSettingsV4['advanced'] & { 15 | useWebCodecs: boolean; 16 | }, 17 | privacy: Omit, 18 | save: Omit & { 19 | alwaysProxy: boolean; 20 | localProcessing: boolean; 21 | allowH265: boolean; 22 | convertGif: boolean; 23 | youtubeBetterAudio: boolean; 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /web/src/lib/types/task-manager.ts: -------------------------------------------------------------------------------- 1 | import type { CobaltPipelineItem, CobaltWorkerProgress } from "$lib/types/workers"; 2 | import type { UUID } from "./queue"; 3 | 4 | export type CobaltCurrentTaskItem = { 5 | type: CobaltPipelineItem['worker'], 6 | parentId: UUID, 7 | progress?: CobaltWorkerProgress, 8 | } 9 | 10 | export type CobaltCurrentTasks = { 11 | [id: UUID]: CobaltCurrentTaskItem, 12 | } 13 | -------------------------------------------------------------------------------- /web/src/lib/types/workers.ts: -------------------------------------------------------------------------------- 1 | import type { FileInfo } from "$lib/types/libav"; 2 | import type { UUID } from "./queue"; 3 | 4 | export const resultFileTypes = ["video", "audio", "image"] as const; 5 | 6 | export type CobaltPipelineResultFileType = typeof resultFileTypes[number]; 7 | 8 | export type CobaltWorkerProgress = { 9 | percentage?: number, 10 | speed?: number, 11 | size: number, 12 | }; 13 | 14 | type CobaltFFmpegWorkerArgs = { 15 | files: File[], 16 | ffargs: string[], 17 | output: FileInfo, 18 | }; 19 | 20 | type CobaltPipelineItemBase = { 21 | workerId: UUID, 22 | parentId: UUID, 23 | dependsOn?: UUID[], 24 | }; 25 | 26 | type CobaltRemuxPipelineItem = CobaltPipelineItemBase & { 27 | worker: "remux", 28 | workerArgs: CobaltFFmpegWorkerArgs, 29 | } 30 | 31 | type CobaltEncodePipelineItem = CobaltPipelineItemBase & { 32 | worker: "encode", 33 | workerArgs: CobaltFFmpegWorkerArgs, 34 | } 35 | 36 | type CobaltFetchPipelineItem = CobaltPipelineItemBase & { 37 | worker: "fetch", 38 | workerArgs: { url: string }, 39 | } 40 | 41 | export type CobaltPipelineItem = CobaltEncodePipelineItem 42 | | CobaltRemuxPipelineItem 43 | | CobaltFetchPipelineItem; 44 | -------------------------------------------------------------------------------- /web/src/lib/util.ts: -------------------------------------------------------------------------------- 1 | import { CobaltFileMetadataKeys, type CobaltFileMetadata } from "$lib/types/api"; 2 | 3 | export const formatFileSize = (size: number | undefined) => { 4 | size ||= 0; 5 | 6 | // gigabyte, megabyte, kilobyte, byte 7 | const units = ['G', 'M', 'K', '']; 8 | while (size >= 1024 && units.length > 1) { 9 | size /= 1024; 10 | units.pop(); 11 | } 12 | 13 | const roundedSize = size.toFixed(2); 14 | const unit = units[units.length - 1] + "B"; 15 | return `${roundedSize} ${unit}`; 16 | } 17 | 18 | export const ffmpegMetadataArgs = (metadata: CobaltFileMetadata) => 19 | Object.entries(metadata).flatMap(([name, value]) => { 20 | if (CobaltFileMetadataKeys.includes(name) && typeof value === "string") { 21 | return [ 22 | '-metadata', 23 | // eslint-disable-next-line no-control-regex 24 | `${name}=${value.replace(/[\u0000-\u0009]/g, "")}` 25 | ] 26 | } 27 | return []; 28 | }); 29 | -------------------------------------------------------------------------------- /web/src/lib/version.ts: -------------------------------------------------------------------------------- 1 | import { readable } from "svelte/store"; 2 | import type { Optional } from "./types/generic"; 3 | import { browser } from "$app/environment"; 4 | 5 | type VersionResponse = { 6 | commit: string; 7 | branch: string; 8 | remote: string; 9 | version: string; 10 | } 11 | 12 | export const version = readable>( 13 | undefined, 14 | (set) => { 15 | if (!browser) return; 16 | 17 | fetch('/version.json') 18 | .then(r => r.json()) 19 | .then(set) 20 | .catch(() => {}) 21 | } 22 | ) 23 | -------------------------------------------------------------------------------- /web/src/routes/+error.svelte: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /web/src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true; 2 | export const ssr = true; 3 | 4 | import { browser } from '$app/environment'; 5 | 6 | import { get } from 'svelte/store'; 7 | import type { Load } from '@sveltejs/kit'; 8 | 9 | import { loadTranslations, defaultLocale } from '$lib/i18n/translations'; 10 | 11 | export const load: Load = async ({ url }) => { 12 | const { pathname } = url; 13 | 14 | let preferredLocale = defaultLocale; 15 | 16 | if (browser) { 17 | preferredLocale = get((await import('$lib/i18n/locale')).default); 18 | } 19 | 20 | await loadTranslations(preferredLocale, pathname); 21 | return {}; 22 | } 23 | -------------------------------------------------------------------------------- /web/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | {$t("general.cobalt")} 11 | 12 | 13 | 14 |
15 | 16 |
21 | 22 | 23 |
24 |
25 | {$t("save.terms.note.agreement")} 26 | {$t("save.terms.note.link")} 27 |
28 |
29 | 30 | 66 | -------------------------------------------------------------------------------- /web/src/routes/_headers/+server.ts: -------------------------------------------------------------------------------- 1 | export function GET() { 2 | const _headers = { 3 | "/*": { 4 | "Cross-Origin-Opener-Policy": "same-origin", 5 | "Cross-Origin-Embedder-Policy": "require-corp", 6 | } 7 | } 8 | 9 | return new Response( 10 | Object.entries(_headers).map( 11 | ([path, headers]) => [ 12 | path, 13 | Object.entries(headers).map( 14 | ([key, value]) => ` ${key}: ${value}` 15 | ) 16 | ].flat().join("\n") 17 | ).join("\n\n") 18 | ); 19 | } 20 | 21 | export const prerender = true; 22 | -------------------------------------------------------------------------------- /web/src/routes/about/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /web/src/routes/about/[page]/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /web/src/routes/about/[page]/+page.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentType, SvelteComponent } from 'svelte'; 2 | import { get } from 'svelte/store'; 3 | import { error } from '@sveltejs/kit'; 4 | 5 | import type { PageLoad } from './$types'; 6 | 7 | import locale from '$lib/i18n/locale'; 8 | import type { DefaultImport } from '$lib/types/generic'; 9 | import { defaultLocale } from '$lib/i18n/translations'; 10 | 11 | const pages = import.meta.glob('$i18n/*/about/*.md'); 12 | 13 | export const load: PageLoad = async ({ params }) => { 14 | const getPage = (locale: string) => Object.keys(pages).find( 15 | file => file.endsWith(`${locale}/about/${params.page}.md`) 16 | ); 17 | 18 | const componentPath = getPage(get(locale)) || getPage(defaultLocale); 19 | if (componentPath) { 20 | type Component = ComponentType; 21 | const componentImport = pages[componentPath] as DefaultImport; 22 | 23 | return { component: (await componentImport()).default } 24 | } 25 | 26 | error(404, 'Not found'); 27 | }; 28 | 29 | export const prerender = true; 30 | -------------------------------------------------------------------------------- /web/src/routes/settings/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /web/src/routes/settings/accessibility/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | 19 | 25 | 26 | 27 | 31 | 37 | 38 | 39 | {#if device.supports.haptics} 40 | 44 | 50 | 51 | {/if} 52 | -------------------------------------------------------------------------------- /web/src/routes/settings/advanced/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /web/src/routes/settings/local/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 16 | 17 | 18 | {#if env.ENABLE_WEBCODECS} 19 | 20 | 26 | 27 | {/if} 28 | -------------------------------------------------------------------------------- /web/src/routes/settings/privacy/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 17 | 18 | 19 | {#if env.PLAUSIBLE_ENABLED} 20 | 21 | 27 |
28 | 29 | {$t("settings.privacy.analytics.learnmore")} 30 | 31 |
32 |
33 | {/if} 34 | 35 | 40 | -------------------------------------------------------------------------------- /web/src/routes/version.json/+server.ts: -------------------------------------------------------------------------------- 1 | import { json } from "@sveltejs/kit"; 2 | import { getCommit, getBranch, getRemote, getVersion } from "@imput/version-info"; 3 | 4 | export async function GET() { 5 | return json({ 6 | commit: await getCommit(), 7 | branch: await getBranch(), 8 | remote: await getRemote(), 9 | version: await getVersion() 10 | }); 11 | } 12 | 13 | export const prerender = true; 14 | -------------------------------------------------------------------------------- /web/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/favicon.png -------------------------------------------------------------------------------- /web/static/fonts/noto-mono-cobalt.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/fonts/noto-mono-cobalt.woff2 -------------------------------------------------------------------------------- /web/static/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /web/static/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /web/static/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /web/static/icons/generic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/icons/generic.png -------------------------------------------------------------------------------- /web/static/icons/maskable/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/icons/maskable/128.png -------------------------------------------------------------------------------- /web/static/icons/maskable/192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/icons/maskable/192.png -------------------------------------------------------------------------------- /web/static/icons/maskable/384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/icons/maskable/384.png -------------------------------------------------------------------------------- /web/static/icons/maskable/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/icons/maskable/48.png -------------------------------------------------------------------------------- /web/static/icons/maskable/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/icons/maskable/512.png -------------------------------------------------------------------------------- /web/static/icons/maskable/72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/icons/maskable/72.png -------------------------------------------------------------------------------- /web/static/icons/maskable/96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/icons/maskable/96.png -------------------------------------------------------------------------------- /web/static/meowbalt/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/meowbalt/error.png -------------------------------------------------------------------------------- /web/static/meowbalt/fast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/meowbalt/fast.png -------------------------------------------------------------------------------- /web/static/meowbalt/question.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/meowbalt/question.png -------------------------------------------------------------------------------- /web/static/meowbalt/smile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/meowbalt/smile.png -------------------------------------------------------------------------------- /web/static/meowbalt/think.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/meowbalt/think.png -------------------------------------------------------------------------------- /web/static/update-banners/bettertogether.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/bettertogether.webp -------------------------------------------------------------------------------- /web/static/update-banners/catmakeup.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/catmakeup.webp -------------------------------------------------------------------------------- /web/static/update-banners/catphonestand.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/catphonestand.webp -------------------------------------------------------------------------------- /web/static/update-banners/catroomba.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/catroomba.webp -------------------------------------------------------------------------------- /web/static/update-banners/catsleep.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/catsleep.webp -------------------------------------------------------------------------------- /web/static/update-banners/catspeed.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/catspeed.webp -------------------------------------------------------------------------------- /web/static/update-banners/catswitchboxes.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/catswitchboxes.webp -------------------------------------------------------------------------------- /web/static/update-banners/cattired.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/cattired.webp -------------------------------------------------------------------------------- /web/static/update-banners/cobalt10.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/cobalt10.webp -------------------------------------------------------------------------------- /web/static/update-banners/developers.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/developers.webp -------------------------------------------------------------------------------- /web/static/update-banners/happymeowth.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/happymeowth.webp -------------------------------------------------------------------------------- /web/static/update-banners/meowbalt_very_fast.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/meowbalt_very_fast.webp -------------------------------------------------------------------------------- /web/static/update-banners/meowth101hammer.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/meowth101hammer.webp -------------------------------------------------------------------------------- /web/static/update-banners/meowth7eleven.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/meowth7eleven.webp -------------------------------------------------------------------------------- /web/static/update-banners/meowth_beach.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/meowth_beach.webp -------------------------------------------------------------------------------- /web/static/update-banners/meowthball.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/meowthball.webp -------------------------------------------------------------------------------- /web/static/update-banners/meowthbusinessman.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/meowthbusinessman.webp -------------------------------------------------------------------------------- /web/static/update-banners/meowthcenter.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/meowthcenter.webp -------------------------------------------------------------------------------- /web/static/update-banners/meowthcooking.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/meowthcooking.webp -------------------------------------------------------------------------------- /web/static/update-banners/meowthhammer.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/meowthhammer.webp -------------------------------------------------------------------------------- /web/static/update-banners/meowthpolishegg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/meowthpolishegg.webp -------------------------------------------------------------------------------- /web/static/update-banners/meowthproductions.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/meowthproductions.webp -------------------------------------------------------------------------------- /web/static/update-banners/meowthsnap.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/meowthsnap.webp -------------------------------------------------------------------------------- /web/static/update-banners/meowthstrong.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/meowthstrong.webp -------------------------------------------------------------------------------- /web/static/update-banners/millionusers.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/millionusers.webp -------------------------------------------------------------------------------- /web/static/update-banners/newdomain.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/newdomain.webp -------------------------------------------------------------------------------- /web/static/update-banners/newyear2025.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/newyear2025.webp -------------------------------------------------------------------------------- /web/static/update-banners/onemillionr.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/onemillionr.webp -------------------------------------------------------------------------------- /web/static/update-banners/shutup.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/shutup.webp -------------------------------------------------------------------------------- /web/static/update-banners/twitchupdate.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/twitchupdate.webp -------------------------------------------------------------------------------- /web/static/update-banners/valentines.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imputnet/cobalt/b4a53d0fde3fa50453854a6e3060b302152f786f/web/static/update-banners/valentines.webp -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler", 13 | "types": ["turnstile-types"], 14 | "lib": ["WebWorker"] 15 | } 16 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 17 | // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files 18 | // 19 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 20 | // from the referenced tsconfig.json - TypeScript does not merge them in 21 | } 22 | --------------------------------------------------------------------------------