[
  {
    "path": ".deepsource.toml",
    "content": "version = 1\n\n[[analyzers]]\nname = \"javascript\"\n\n  [analyzers.meta]\n  environment = [\"nodejs\"]"
  },
  {
    "path": ".dockerignore",
    "content": "# OS directory info files\n.DS_Store\ndesktop.ini\n\n# node\nnode_modules\n\n# static build\nbuild\n\n# secrets\n.env\n.env.*\n!.env.example\ncookies.json\n\n# docker\ndocker-compose.yml\n\n# ide\n.vscode\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug-main-instance.yml",
    "content": "name: main instance bug report\ndescription: \"report an issue with cobalt.tools or api.cobalt.tools\"\nlabels: [\"main instance issue\"]\nbody:\n  - type: textarea\n    id: bug-description\n    attributes:\n      label: bug description\n      description: \"clear and concise description of what the issue is.\"\n    validations:\n      required: true\n  - type: textarea\n    id: repro-steps\n    attributes:\n      label: reproduction steps\n      description: steps to reproduce the described behavior.\n      placeholder: |\n        1. go to '...'\n        2. click on '....'\n        3. download [media type] from [service]\n        4. see error\n    validations:\n      required: true\n  - type: textarea\n    id: screenshots\n    attributes:\n      label: screenshots\n      description: if applicable, add screenshots or screen recordings to support your explanation.\n  - type: textarea\n    id: links\n    attributes:\n      label: links\n      description: if applicable, add links that cause the issue. more = better.\n      render: shell\n  - type: input\n    id: platform\n    attributes:\n      label: platform information\n      description: \"the operating system, browser and their versions where you encounter the issue\"\n      placeholder: safari 7 on mac os x 10.8\n    validations:\n      required: true\n  - type: textarea\n    id: more-context\n    attributes:\n      label: additional context\n      description: add any other context about the problem here if applicable."
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report.yml",
    "content": "name: bug report\ndescription: report a global issue with the cobalt codebase\nlabels: [\"bug\"]\nbody:\n  - type: textarea\n    id: bug-description\n    attributes:\n      label: bug description\n      description: \"clear and concise description of what the issue is.\"\n    validations:\n      required: true\n  - type: textarea\n    id: repro-steps\n    attributes:\n      label: reproduction steps\n      description: steps to reproduce the described behavior.\n      placeholder: |\n        1. go to '...'\n        2. click on '....'\n        3. download [media type] from [service]\n        4. see error\n    validations:\n      required: true\n  - type: textarea\n    id: screenshots\n    attributes:\n      label: screenshots\n      description: if applicable, add screenshots or screen recordings to support your explanation.\n  - type: textarea\n    id: links\n    attributes:\n      label: links\n      description: if applicable, add links that cause the issue. more = better.\n      render: shell\n  - type: input\n    id: platform\n    attributes:\n      label: platform information\n      description: \"the operating system, browser and their versions where you encounter the issue\"\n      placeholder: safari 7 on mac os x 10.8\n    validations:\n      required: true\n  - type: textarea\n    id: more-context\n    attributes:\n      label: additional context\n      description: add any other context about the problem here if applicable."
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: discord community\n    url: https://discord.gg/pQPt8HBUPu\n    about: |\n      ask questions and discuss cobalt with others at any time.\n      usually faster responses as more people are there to help."
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature-request.yml",
    "content": "name: feature request\ndescription: suggest a feature for cobalt\nlabels: [\"feature request\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        thanks for taking the time to make a feature request!\n        before you start, please make to read the \"adding features or support for services\" section of\n        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.\n  - type: textarea\n    id: feat-description\n    attributes:\n      label: describe the feature you'd like to see\n      description: \"clear and concise description of the feature you want to see in cobalt.\"\n    validations:\n      required: true\n  - type: textarea\n    id: more-context\n    attributes:\n      label: additional context\n      description: add any other context about the problem here if applicable."
  },
  {
    "path": ".github/ISSUE_TEMPLATE/hosting-help.yml",
    "content": "name: instance hosting help\ndescription: ask any question regarding cobalt instance hosting\nlabels: [\"instance hosting help\"]\nbody:\n  - type: textarea\n    id: problem-description\n    attributes:\n      label: problem description\n      description: |\n        describe what issue you're having, clearly and concisely.\n        support your description with screenshots/links/etc when needed.\n    validations:\n      required: true\n  - type: textarea\n    id: configuration\n    attributes:\n      label: your instance configuration\n      description: |\n        if applicable, add or describe your instance configuration (e.g. compose file) or any changes you made to it.\n        please **do not share senstive information** such as secret keys or the contents of your cookies file!\n      render: shell"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/service-request.yml",
    "content": "name: service request\ndescription: \"request service support in cobalt\"\ntitle: \"add support for [service name]\"\nlabels: [\"service request\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        thanks for taking the time to make a service request!\n        before you start, please make to read the \"adding features or support for services\" section of\n        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.\n  - type: input\n    id: service-name\n    attributes:\n      label: service name\n    validations:\n      required: true\n  - type: textarea\n    id: service-description\n    attributes:\n      label: service description\n      description: a brief description of what the service is and/or what it provides\n    validations:\n      required: true\n  - type: textarea\n    id: link-samples\n    attributes:\n      label: link samples\n      description: |\n        list of links that cobalt should recognize.\n        could be regular video link, shared video link, mobile video link, shortened link, etc.\n      render: shell\n    validations:\n      required: true\n  - type: textarea\n    id: more-context\n    attributes:\n      label: additional context\n      description: any additional context or screenshots should go here.\n"
  },
  {
    "path": ".github/test.sh",
    "content": "#!/bin/bash\nset -e\n\n# thx: https://stackoverflow.com/a/27601038\nwaitport() {\n    ATTEMPTS=50\n    while [ $((ATTEMPTS-=1)) -gt 0 ] && ! nc -z localhost $1; do   \n        sleep 0.1\n    done\n\n    [ \"$ATTEMPTS\" != 0 ] || exit 1\n}\n\ntest_api() {\n    waitport 3000\n    curl -m 3 http://localhost:3000/\n    API_RESPONSE=$(curl -m 10 http://localhost:3000/ \\\n         -X POST \\\n         -H \"Accept: application/json\" \\\n         -H \"Content-Type: application/json\" \\\n         -d '{\"url\":\"https://garfield-69.tumblr.com/post/696499862852780032\",\"alwaysProxy\":true}')\n\n    echo \"API_RESPONSE=$API_RESPONSE\"\n    STATUS=$(echo \"$API_RESPONSE\" | jq -r .status)\n    STREAM_URL=$(echo \"$API_RESPONSE\" | jq -r .url)\n    [ \"$STATUS\" = tunnel ] || exit 1;\n    S=$(curl -I -m 10 \"$STREAM_URL\")\n\n    CONTENT_LENGTH=$(echo \"$S\" \\\n                        | grep -i content-length \\\n                        | cut -d' ' -f2 \\\n                        | tr -d '\\r')\n\n    echo \"$CONTENT_LENGTH\"\n    [ \"$CONTENT_LENGTH\" = 0 ] && exit 1\n    if [ \"$CONTENT_LENGTH\" -lt 512 ]; then\n        exit 1\n    fi\n}\n\nsetup_api() {\n    export API_PORT=3000\n    export API_URL=http://localhost:3000\n    timeout 10 pnpm run --prefix api start &\n    API_PID=$!\n}\n\nsetup_web() {\n    pnpm run --prefix web check\n    pnpm run --prefix web build\n}\n\ncd \"$(git rev-parse --show-toplevel)\"\npnpm install --frozen-lockfile\n\nif [ \"$1\" = \"api\" ]; then\n    setup_api\n    test_api\n    [ \"$API_PID\" != \"\" ] \\\n        && kill \"$API_PID\"\nelif [ \"$1\" = \"web\" ]; then\n    setup_web\nelse\n    echo \"usage: $0 <api/web>\" >&2\n    exit 1\nfi\n\nwait || exit $?\n"
  },
  {
    "path": ".github/workflows/codeql.yml",
    "content": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# You may wish to alter this file to override the set of languages analyzed,\n# or to provide custom queries or build logic.\n#\n# ******** NOTE ********\n# We have attempted to detect the languages in your repository. Please check\n# the `language` matrix defined below to confirm you have the correct set of\n# supported CodeQL languages.\n#\nname: \"CodeQL\"\n\non:\n  push:\n    branches:\n      - '**'\n  pull_request:\n    branches: [ \"main\", \"7\" ]\n  schedule:\n    - cron: '33 7 * * 5'\n\njobs:\n  analyze:\n    name: Analyze (${{ matrix.language }})\n    # Runner size impacts CodeQL analysis time. To learn more, please see:\n    #   - https://gh.io/recommended-hardware-resources-for-running-codeql\n    #   - https://gh.io/supported-runners-and-hardware-resources\n    #   - https://gh.io/using-larger-runners (GitHub.com only)\n    # Consider using larger runners or machines with greater resources for possible analysis time improvements.\n    runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}\n    permissions:\n      # required for all workflows\n      security-events: write\n\n      # required to fetch internal or private CodeQL packs\n      packages: read\n\n      # only required for workflows in private repositories\n      actions: read\n      contents: read\n\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n        - language: javascript-typescript\n          build-mode: none\n        # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'\n        # Use `c-cpp` to analyze code written in C, C++ or both\n        # Use 'java-kotlin' to analyze code written in Java, Kotlin or both\n        # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both\n        # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,\n        # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.\n        # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how\n        # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v4\n\n    # Initializes the CodeQL tools for scanning.\n    - name: Initialize CodeQL\n      uses: github/codeql-action/init@v3\n      with:\n        languages: ${{ matrix.language }}\n        build-mode: ${{ matrix.build-mode }}\n        # If you wish to specify custom queries, you can do so here or in a config file.\n        # By default, queries listed here will override any specified in a config file.\n        # Prefix the list here with \"+\" to use these queries and those in the config file.\n\n        # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs\n        # queries: security-extended,security-and-quality\n\n    # If the analyze step fails for one of the languages you are analyzing with\n    # \"We were unable to automatically build your code\", modify the matrix above\n    # to set the build mode to \"manual\" for that language. Then modify this step\n    # to build your code.\n    # ℹ️ Command-line programs to run using the OS shell.\n    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun\n    - if: matrix.build-mode == 'manual'\n      shell: bash\n      run: |\n        echo 'If you are using a \"manual\" build mode for one or more of the' \\\n          'languages you are analyzing, replace this with the commands to build' \\\n          'your code, for example:'\n        echo '  make bootstrap'\n        echo '  make release'\n        exit 1\n\n    - name: Perform CodeQL Analysis\n      uses: github/codeql-action/analyze@v3\n      with:\n        category: \"/language:${{matrix.language}}\"\n"
  },
  {
    "path": ".github/workflows/docker-develop.yml",
    "content": "name: Build development Docker image\n\non:\n  workflow_dispatch:\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n\njobs:\n  build-and-push-image:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to the Container registry\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Get release metadata\n        id: release-meta\n        run: |\n            version=$(cat package.json | jq -r .version)\n            echo \"commit_short=$(git rev-parse --short HEAD)\" >> $GITHUB_OUTPUT\n            echo \"version=$version\" >> $GITHUB_OUTPUT\n            echo \"major_version=$(echo \"$version\" | cut -d. -f1)\" >> $GITHUB_OUTPUT\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          tags: type=raw,value=develop\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          platforms: linux/amd64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n"
  },
  {
    "path": ".github/workflows/docker-staging.yml",
    "content": "name: Build staging Docker image\n\non:\n  workflow_dispatch:\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n\njobs:\n  build-and-push-image:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to the Container registry\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Get release metadata\n        id: release-meta\n        run: |\n            version=$(cat package.json | jq -r .version)\n            echo \"commit_short=$(git rev-parse --short HEAD)\" >> $GITHUB_OUTPUT\n            echo \"version=$version\" >> $GITHUB_OUTPUT\n            echo \"major_version=$(echo \"$version\" | cut -d. -f1)\" >> $GITHUB_OUTPUT\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          tags: type=raw,value=staging\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          platforms: linux/amd64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n"
  },
  {
    "path": ".github/workflows/docker.yml",
    "content": "name: Build release Docker image\n\non:\n  workflow_dispatch:\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n\njobs:\n  build-and-push-image:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to the Container registry\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Get release metadata\n        id: release-meta\n        run: |\n            version=$(cat api/package.json | jq -r .version)\n            echo \"commit_short=$(git rev-parse --short HEAD)\" >> $GITHUB_OUTPUT\n            echo \"version=$version\" >> $GITHUB_OUTPUT\n            echo \"major_version=$(echo \"$version\" | cut -d. -f1)\" >> $GITHUB_OUTPUT\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          tags: |\n            type=raw,value=latest\n            type=raw,value=${{ steps.release-meta.outputs.version }}\n            type=raw,value=${{ steps.release-meta.outputs.major_version }}\n            type=raw,value=${{ steps.release-meta.outputs.version }}-${{ steps.release-meta.outputs.commit_short }}\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n"
  },
  {
    "path": ".github/workflows/fast-forward.yml",
    "content": "name: fast-forward\non:\n  issue_comment:\n    types: [created, edited]\njobs:\n  fast-forward:\n    # Only run if the comment contains the /fast-forward command.\n    if: ${{ contains(github.event.comment.body, '/fast-forward')\n            && github.event.issue.pull_request }}\n    runs-on: ubuntu-latest\n\n    permissions:\n      contents: write\n      pull-requests: write\n      issues: write\n\n    steps:\n      - name: Fast forwarding\n        uses: sequoia-pgp/fast-forward@v1\n        with:\n          merge: true\n          comment: 'on-error'"
  },
  {
    "path": ".github/workflows/test-services.yml",
    "content": "name: Run service tests\n\non:\n  pull_request:\n  push:\n    paths:\n      - api/**\n      - packages/**\n\njobs:\n  check-services:\n    name: test service functionality\n    runs-on: ubuntu-latest\n    outputs:\n      services: ${{ steps.checkServices.outputs.service_list }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: pnpm/action-setup@v4\n      - id: checkServices\n        run: pnpm i --frozen-lockfile && echo \"service_list=$(node api/src/util/test get-services)\" >> \"$GITHUB_OUTPUT\"\n\n  test-services:\n    needs: check-services\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        service: ${{ fromJson(needs.check-services.outputs.services) }}\n    name: \"test service: ${{ matrix.service }}\"\n    steps:\n      - uses: actions/checkout@v4\n      - uses: pnpm/action-setup@v4\n      - run: pnpm i --frozen-lockfile\n      - run: node api/src/util/test run-tests-for ${{ matrix.service }}\n        env:\n          HTTP_PROXY: ${{ secrets.API_EXTERNAL_PROXY }}\n          TEST_IGNORE_SERVICES: ${{ vars.TEST_IGNORE_SERVICES }}\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Run tests\n\non:\n  pull_request:\n  push:\n\njobs:\n  check-lockfile:\n    name: check lockfile correctness\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: pnpm/action-setup@v4\n      - name: Check that lockfile does not need an update\n        run: pnpm install --frozen-lockfile\n\n  test-web:\n    name: web sanity check\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 'lts/*'\n      - uses: pnpm/action-setup@v4\n      - run: .github/test.sh web\n        env:\n          WEB_DEFAULT_API: https://api.dummy.example/\n\n  test-api:\n    name: api sanity check\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: pnpm/action-setup@v4\n      - run: .github/test.sh api\n"
  },
  {
    "path": ".gitignore",
    "content": "# OS directory info files\n.DS_Store\ndesktop.ini\n\n# node\nnode_modules\n\n# static build\nbuild\n\n# secrets\n.env\n.env.*\n!.env.example\ncookies.json\nkeys.json\n\n# docker\ndocker-compose.yml\n\n# ide\n.vscode\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# contributing to cobalt\nif you're reading this, you are probably interested in contributing to cobalt, which we are very thankful for :3\n\nthis document serves as a guide to help you make contributions that we can merge into the cobalt codebase.\n\n## translations\nwe are currently accepting translations via the [i18n platform](https://i18n.imput.net).\n\nthank you for showing interest in making cobalt more accessible around the world, we really appreciate it! here are some guidelines for how a cobalt translation should look:\n\n- cobalt's writing style is informal. please do not use formal language, unless there is no other way to express the same idea of the original text in your language.\n- all cobalt text is written in lowercase. this is a stylistic choice, please do not capitalize translated sentences.\n- do not translate the name \"cobalt\", or \"imput\"\n- you can translate \"meowbalt\" into whatever your language's equivalent of _meow_ is (e.g. _miaubalt_ in German)\n- **please don't translate cobalt into languages which you are not experienced in.** we can use google translate ourselves, but we would prefer cobalt to be translated by humans, not computers.\n\nif your language does not exist on the translation platform yet, you can request to add it by adding it to any of cobalt's components (e.g. [here](https://i18n.imput.net/projects/cobalt/about/)).\n\nbefore translating a piece of text, check that no one has made a translation yet. pending translations are displayed in the **Suggestions** tab on the translate page. if someone already made a suggestion, and you think it's correct, you can upvote it! this helps us distinguish that a translation is correct.\n\nif no one has submitted a translation, or the submitted translation seems wrong to you, you can submit your translation by clicking the **Suggest** button for each individual string, which sends it off for human review. we will then check it to to ensure no malicious translations are submitted, and add it to cobalt.\n\nif any translation string's meaning seems unclear to you, please leave a comment on the *Comments* tab, and we will either add an explanation or a screenshot.\n\n## adding features or support for services\nbefore putting in the effort to implement a feature, it's worth considering whether it would be appropriate to add it to cobalt. the cobalt api is built to assist people **only with downloading freely accessible content**. other functionality, such as:\n- downloading paid / not publicly accessible content\n- downloading content protected by DRM\n- scraping unrelated information & exposing it outside of file metadata\n\nwill not be reviewed or merged.\n\nif you plan on adding a feature or support for a service, but are unsure whether it would be appropriate, it's best to open an issue and discuss it beforehand.\n\n## git\nwhen contributing code to cobalt, there are a few guidelines in place to ensure that the code history is readable and comprehensible.\n\n### clean commit messages\ninternally, we use a format similar to [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) - the first part signifies which part of the code you are changing (the *scope*), and the second part explains the change. for inspiration on how to write appropriate commit titles, you can take a look at the [commit history](https://github.com/imputnet/cobalt/commits/).\n\nthe scope is not strictly defined, you can write whatever you find most fitting for the particular change. suppose you are changing a small part of a more significant part of the codebase. in that case, you can specify both the larger and smaller scopes in the commit message for clarity (e.g., if you were changing something in internal streams, the commit could be something like `api/stream: fix object not being handled properly`).\n\nif you think a change deserves further explanation, we encourage you to write a short explanation in the commit message ([example](https://github.com/imputnet/cobalt/commit/31be60484de8eaf63bba8a4f508e16438aa7ba6e)), which will save both you and us time having to enquire about the change, and you explaining the reason behind it.\n\nif your contribution has uninformative commit titles, you may be asked to interactively rebase your branch and amend each commit to include a meaningful title.\n\n### clean commit history\nif your branch is out of date and/or has some merge conflicts with the `current` branch, you should **rebase** it instead of merging. this prevents meaningless merge commits from being included in your branch, which would then end up in the cobalt git history.\n\nif you find a mistake or bug in your code before it's merged or reviewed, instead of making a brand new commit to fix it, it would be preferable to amend that specific commit where the mistake was first introduced. this also helps us easily revert a commit if we discover that it introduced a bug or some unwanted behavior.\n- if the commit you are fixing is the latest one, you can add your files to staging and then use `git commit --amend` to apply the change.\n- if the commit is somewhere deeper in your branch, you can use `git commit --fixup=HASH`, where *`HASH`* is the commit you are fixing.\n    - afterward, you must interactively rebase your branch with `git rebase -i current --autosquash`.\n      this will open up an editor, but you don't need to do anything else except save the file and exit.\n- once you do either of these things, you will need to do a **force push** to your branch with `git push --force-with-lease`.\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM node:24-alpine AS base\nENV PNPM_HOME=\"/pnpm\"\nENV PATH=\"$PNPM_HOME:$PATH\"\n\nFROM base AS build\nWORKDIR /app\nCOPY . /app\n\nRUN corepack enable\nRUN apk add --no-cache python3 alpine-sdk\n\nRUN --mount=type=cache,id=pnpm,target=/pnpm/store \\\n    pnpm install --prod --frozen-lockfile\n\nRUN pnpm deploy --filter=@imput/cobalt-api --prod /prod/api\n\nFROM base AS api\nWORKDIR /app\n\nCOPY --from=build --chown=node:node /prod/api /app\nCOPY --from=build --chown=node:node /app/.git /app/.git\n\nUSER node\n\nEXPOSE 9000\nCMD [ \"node\", \"src/cobalt\" ]\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    save what you love with cobalt.\n    Copyright (C) 2024 imput\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n    <br/>\n    <p>\n        <img src=\"web/static/favicon.png\" title=\"cobalt\" alt=\"cobalt logo\" width=\"100\" />\n    </p>\n    <p>\n        best way to save what you love\n        <br/>\n        <a href=\"https://cobalt.tools\">\n            cobalt.tools\n        </a>\n    </p>\n    <p>\n        <a href=\"https://discord.gg/pQPt8HBUPu\">\n            💬 community discord server\n        </a>\n        <br/>\n        <a href=\"https://x.com/justusecobalt\">\n            🐦 twitter\n        </a>\n        <a href=\"https://bsky.app/profile/cobalt.tools\">\n            🦋 bluesky\n        </a>\n    </p>\n    <br/>\n</div>\n\ncobalt is a media downloader that doesn't piss you off. it's friendly, efficient, and doesn't have ads, trackers, paywalls or other nonsense.\n\npaste the link, get the file, move on. that simple, just how it should be.\n\n### cobalt monorepo\nthis monorepo includes source code for api, frontend, and related packages:\n- [api tree & readme](/api/)\n- [web tree & readme](/web/)\n- [packages tree](/packages/)\n\nit also includes documentation in the [docs tree](/docs/):\n- [how to run a cobalt instance](/docs/run-an-instance.md)\n- [how to protect a cobalt instance](/docs/protect-an-instance.md)\n- [cobalt api instance environment variables](/docs/api-env-variables.md)\n- [cobalt api documentation](/docs/api.md)\n\n### ethics\ncobalt is a tool that makes downloading public content easier. it takes **zero liability**.\nthe end user is responsible for what they download, how they use and distribute that content.\ncobalt never caches any content, it [works like a fancy proxy](/api/src/stream/).\n\ncobalt is in no way a piracy tool and cannot be used as such.\nit can only download free & publicly accessible content.\nsame content can be downloaded via dev tools of any modern web browser.\n\n### contributing\nif you're considering contributing to cobalt, first of all, thank you! check the [contribution guidelines here](/CONTRIBUTING.md) before getting started, they'll help you do your best right away.\n\n### thank you\ncobalt is sponsored by [royalehosting.net](https://royalehosting.net/?partner=cobalt). a part of our infrastructure is hosted on their network. we really appreciate their kindness and support!\n\n### licenses\nfor relevant licensing information, see the [api](api/README.md) and [web](web/README.md) READMEs.\nunless specified otherwise, the remainder of this repository is licensed under [AGPL-3.0](LICENSE).\n"
  },
  {
    "path": "api/LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    save what you love with cobalt.\n    Copyright (C) 2024 imput\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "api/README.md",
    "content": "# cobalt api\nthis directory includes the source code for cobalt api. it's made with [express.js](https://www.npmjs.com/package/express) and love!\n\n## running your own instance\nif you want to run your own instance for whatever purpose, [follow this guide](/docs/run-an-instance.md).\nwe recommend to use docker compose unless you intend to run cobalt for developing/debugging purposes.\n\n## accessing the api\nthere is currently no publicly available pre-hosted api.\nwe recommend [deploying your own instance](/docs/run-an-instance.md) if you wish to use the cobalt api.\n\nyou can read [the api documentation here](/docs/api.md).\n\n## supported services\nthis list is not final and keeps expanding over time!\nif the desired service isn't supported yet, feel free to create an appropriate issue (or a pull request 👀).\n\n| service           | video + audio | only audio | only video | metadata | rich file names |\n| :--------         | :-----------: | :--------: | :--------: | :------: | :-------------: |\n| bilibili          | ✅            | ✅         | ✅         | ➖         | ➖              |\n| bluesky           | ✅            | ✅         | ✅         | ➖         | ➖              |\n| dailymotion       | ✅            | ✅         | ✅         | ✅         | ✅              |\n| instagram         | ✅            | ✅         | ✅         | ➖         | ➖              |\n| facebook          | ✅            | ❌         | ✅         | ➖         | ➖              |\n| loom              | ✅            | ❌         | ✅         | ✅         | ➖              |\n| newgrounds        | ✅            | ✅         | ✅         | ✅         | ✅              |\n| ok.ru             | ✅            | ❌         | ✅         | ✅         | ✅              |\n| pinterest         | ✅            | ✅         | ✅         | ➖         | ➖              |\n| reddit            | ✅            | ✅         | ✅         | ❌         | ❌              |\n| rutube            | ✅            | ✅         | ✅         | ✅         | ✅              |\n| snapchat          | ✅            | ✅         | ✅         | ➖         | ➖              |\n| soundcloud        | ➖            | ✅         | ➖         | ✅         | ✅              |\n| streamable        | ✅            | ✅         | ✅         | ➖         | ➖              |\n| tiktok            | ✅            | ✅         | ✅         | ❌         | ❌              |\n| tumblr            | ✅            | ✅         | ✅         | ➖         | ➖              |\n| twitch clips      | ✅            | ✅         | ✅         | ✅         | ✅              |\n| twitter/x         | ✅            | ✅         | ✅         | ➖         | ➖              |\n| vimeo             | ✅            | ✅         | ✅         | ✅         | ✅              |\n| vk videos & clips | ✅            | ❌         | ✅         | ✅         | ✅              |\n| xiaohongshu       | ✅            | ✅         | ✅         | ➖         | ➖              |\n| youtube           | ✅            | ✅         | ✅         | ✅         | ✅              |\n\n| emoji   | meaning                 |\n| :-----: | :---------------------- |\n| ✅      | supported               |\n| ➖      | unreasonable/impossible |\n| ❌      | not supported           |\n\n### additional notes or features (per service)\n| service    | notes or features                                                                                                    |\n| :--------  | :-----                                                                                                               |\n| instagram  | supports reels, photos, and videos. lets you pick what to save from multi-media posts.                               |\n| facebook   | supports public accessible videos content only.                                                                      |\n| pinterest  | supports photos, gifs, videos and stories.                                                                           |\n| reddit     | supports gifs and videos.                                                                                            |\n| snapchat   | supports spotlights and stories. lets you pick what to save from stories.                                            |\n| rutube     | supports yappy & private links.                                                                                      |\n| soundcloud | supports private links.                                                                                              |\n| tiktok     | supports videos with or without watermark, images from slideshow without watermark, and full (original) audios.      |\n| twitter/x  | lets you pick what to save from multi-media posts. may not be 100% reliable due to current management.               |\n| vimeo      | audio downloads are only available for dash.                                                                         |\n| youtube    | supports videos, music, and shorts. 8K, 4K, HDR, VR, and high FPS videos. rich metadata & dubs. h264/av1/vp9 codecs. |\n\n## license\ncobalt api code is licensed under [AGPL-3.0](LICENSE).\n\nthis license allows you to modify, distribute and use the code for any purpose\nas long as you:\n- give appropriate credit to the original repo when using or modifying any parts of the code,\n- provide a link to the license and indicate if changes to the code were made, and\n- release the code under the **same license**\n\n## open source acknowledgements\n### ffmpeg\ncobalt relies on ffmpeg for muxing and encoding media files. ffmpeg is absolutely spectacular and we're privileged to have the ability to use it for free, just like anyone else. we believe it should be way more recognized.\n\nyou can [support ffmpeg here](https://ffmpeg.org/donations.html)!\n\n### youtube.js\ncobalt relies on **[youtube.js](https://github.com/LuanRT/YouTube.js)** for interacting with youtube's innertube api, it wouldn't have been possible without this package.\n\nyou can support the developer via various methods listed on their github page!\n(linked above)\n\n### many others\ncobalt-api also depends on:\n\n- **[content-disposition-header](https://www.npmjs.com/package/content-disposition-header)** to simplify the provision of `content-disposition` headers.\n- **[cors](https://www.npmjs.com/package/cors)** to manage cross-origin resource sharing within expressjs.\n- **[dotenv](https://www.npmjs.com/package/dotenv)** to load environment variables from the `.env` file.\n- **[express](https://www.npmjs.com/package/express)** as the backbone of cobalt servers.\n- **[express-rate-limit](https://www.npmjs.com/package/express-rate-limit)** to rate limit api endpoints.\n- **[ffmpeg-static](https://www.npmjs.com/package/ffmpeg-static)** to get binaries for ffmpeg depending on the platform.\n- **[hls-parser](https://www.npmjs.com/package/hls-parser)** to parse HLS playlists according to spec (very impressive stuff).\n- **[ipaddr.js](https://www.npmjs.com/package/ipaddr.js)** to parse ip addresses (used for rate limiting).\n- **[nanoid](https://www.npmjs.com/package/nanoid)** to generate unique identifiers for each requested tunnel.\n- **[set-cookie-parser](https://www.npmjs.com/package/set-cookie-parser)** to parse cookies that cobalt receives from certain services.\n- **[undici](https://www.npmjs.com/package/undici)** for making http requests.\n- **[url-pattern](https://www.npmjs.com/package/url-pattern)** to match provided links with supported patterns.\n- **[zod](https://www.npmjs.com/package/zod)** to lock down the api request schema.\n- **[@datastructures-js/priority-queue](https://www.npmjs.com/package/@datastructures-js/priority-queue)** for sorting stream caches for future clean up (without redis).\n- **[@imput/psl](https://www.npmjs.com/package/@imput/psl)** as the domain name parser, our fork of [psl](https://www.npmjs.com/package/psl).\n\n...and many other packages that these packages rely on.\n"
  },
  {
    "path": "api/package.json",
    "content": "{\n    \"name\": \"@imput/cobalt-api\",\n    \"description\": \"save what you love\",\n    \"version\": \"11.5\",\n    \"author\": \"imput\",\n    \"exports\": \"./src/cobalt.js\",\n    \"type\": \"module\",\n    \"engines\": {\n        \"node\": \">=18\"\n    },\n    \"scripts\": {\n        \"start\": \"node src/cobalt\",\n        \"test\": \"node src/util/test\",\n        \"token:jwt\": \"node src/util/generate-jwt-secret\"\n    },\n    \"repository\": {\n        \"type\": \"git\",\n        \"url\": \"git+https://github.com/imputnet/cobalt.git\"\n    },\n    \"license\": \"AGPL-3.0\",\n    \"bugs\": {\n        \"url\": \"https://github.com/imputnet/cobalt/issues\"\n    },\n    \"homepage\": \"https://github.com/imputnet/cobalt#readme\",\n    \"dependencies\": {\n        \"@datastructures-js/priority-queue\": \"^6.3.1\",\n        \"@imput/psl\": \"^2.0.4\",\n        \"@imput/version-info\": \"workspace:^\",\n        \"content-disposition-header\": \"0.6.0\",\n        \"cors\": \"^2.8.5\",\n        \"dotenv\": \"^16.0.1\",\n        \"express\": \"^4.21.2\",\n        \"express-rate-limit\": \"^7.4.1\",\n        \"ffmpeg-static\": \"^5.1.0\",\n        \"hls-parser\": \"^0.10.7\",\n        \"ipaddr.js\": \"2.2.0\",\n        \"mime\": \"^4.0.4\",\n        \"nanoid\": \"^5.0.9\",\n        \"set-cookie-parser\": \"2.6.0\",\n        \"undici\": \"^6.21.3\",\n        \"url-pattern\": \"1.0.3\",\n        \"youtubei.js\": \"15.1.1\",\n        \"zod\": \"^3.23.8\"\n    },\n    \"optionalDependencies\": {\n        \"freebind\": \"^0.2.2\",\n        \"rate-limit-redis\": \"^4.2.0\",\n        \"redis\": \"^4.7.0\"\n    }\n}\n"
  },
  {
    "path": "api/src/cobalt.js",
    "content": "import \"dotenv/config\";\n\nimport express from \"express\";\nimport cluster from \"node:cluster\";\n\nimport path from \"path\";\nimport { fileURLToPath } from \"url\";\n\nimport { env, isCluster } from \"./config.js\"\nimport { Red } from \"./misc/console-text.js\";\nimport { initCluster } from \"./misc/cluster.js\";\nimport { setupEnvWatcher } from \"./core/env.js\";\n\nconst app = express();\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename).slice(0, -4);\n\napp.disable(\"x-powered-by\");\n\nif (env.apiURL) {\n    const { runAPI } = await import(\"./core/api.js\");\n\n    if (isCluster) {\n       await initCluster();\n    }\n\n    if (env.envFile) {\n        setupEnvWatcher();\n    }\n\n    runAPI(express, app, __dirname, cluster.isPrimary);\n} else {\n    console.log(\n        Red(\"API_URL env variable is missing, cobalt api can't start.\")\n    )\n}\n"
  },
  {
    "path": "api/src/config.js",
    "content": "import { getVersion } from \"@imput/version-info\";\nimport { loadEnvs, validateEnvs } from \"./core/env.js\";\n\nconst version = await getVersion();\n\nconst canonicalEnv = Object.freeze(structuredClone(process.env));\nconst env = loadEnvs();\n\nconst genericUserAgent = \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36\";\nconst cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`;\n\nexport const setTunnelPort = (port) => env.tunnelPort = port;\nexport const isCluster = env.instanceCount > 1;\nexport const updateEnv = (newEnv) => {\n    const changes = [];\n\n    // tunnelPort is special and needs to get carried over here\n    newEnv.tunnelPort = env.tunnelPort;\n\n    for (const key in env) {\n        if (key === 'subscribe') {\n            continue;\n        }\n\n        if (String(env[key]) !== String(newEnv[key])) {\n            changes.push(key);\n        }\n        env[key] = newEnv[key];\n    }\n\n    return changes;\n}\n\nawait validateEnvs(env);\n\nexport {\n    env,\n    canonicalEnv,\n    genericUserAgent,\n    cobaltUserAgent,\n}\n"
  },
  {
    "path": "api/src/core/api.js",
    "content": "import cors from \"cors\";\nimport http from \"node:http\";\nimport rateLimit from \"express-rate-limit\";\nimport { setGlobalDispatcher, EnvHttpProxyAgent } from \"undici\";\nimport { getCommit, getBranch, getRemote, getVersion } from \"@imput/version-info\";\n\nimport jwt from \"../security/jwt.js\";\nimport stream from \"../stream/stream.js\";\nimport match from \"../processing/match.js\";\n\nimport { env } from \"../config.js\";\nimport { extract } from \"../processing/url.js\";\nimport { Bright, Cyan } from \"../misc/console-text.js\";\nimport { hashHmac } from \"../security/secrets.js\";\nimport { createStore } from \"../store/redis-ratelimit.js\";\nimport { randomizeCiphers } from \"../misc/randomize-ciphers.js\";\nimport { verifyTurnstileToken } from \"../security/turnstile.js\";\nimport { friendlyServiceName } from \"../processing/service-alias.js\";\nimport { verifyStream } from \"../stream/manage.js\";\nimport { createResponse, normalizeRequest, getIP } from \"../processing/request.js\";\nimport { setupTunnelHandler } from \"./itunnel.js\";\n\nimport * as APIKeys from \"../security/api-keys.js\";\nimport * as Cookies from \"../processing/cookie/manager.js\";\nimport * as YouTubeSession from \"../processing/helpers/youtube-session.js\";\n\nconst git = {\n    branch: await getBranch(),\n    commit: await getCommit(),\n    remote: await getRemote(),\n}\n\nconst version = await getVersion();\n\nconst acceptRegex = /^application\\/json(; charset=utf-8)?$/;\n\nconst corsConfig = env.corsWildcard ? {} : {\n    origin: env.corsURL,\n    optionsSuccessStatus: 200\n}\n\nconst fail = (res, code, context) => {\n    const { status, body } = createResponse(\"error\", { code, context });\n    res.status(status).json(body);\n}\n\nexport const runAPI = async (express, app, __dirname, isPrimary = true) => {\n    const startTime = new Date();\n    const startTimestamp = startTime.getTime();\n\n    const getServerInfo = () => {\n        return JSON.stringify({\n            cobalt: {\n                version: version,\n                url: env.apiURL,\n                startTime: `${startTimestamp}`,\n                turnstileSitekey: env.sessionEnabled ? env.turnstileSitekey : undefined,\n                services: [...env.enabledServices].map(e => {\n                    return friendlyServiceName(e);\n                }),\n            },\n            git,\n        });\n    }\n\n    const serverInfo = getServerInfo();\n\n    const handleRateExceeded = (_, res) => {\n        const { body } = createResponse(\"error\", {\n            code: \"error.api.rate_exceeded\",\n            context: {\n                limit: env.rateLimitWindow\n            }\n        });\n        return res.status(429).json(body);\n    };\n\n    const keyGenerator = (req) => hashHmac(getIP(req), 'rate').toString('base64url');\n\n    const sessionLimiter = rateLimit({\n        windowMs: env.sessionRateLimitWindow * 1000,\n        limit: env.sessionRateLimit,\n        standardHeaders: 'draft-6',\n        legacyHeaders: false,\n        keyGenerator,\n        store: await createStore('session'),\n        handler: handleRateExceeded\n    });\n\n    const apiLimiter = rateLimit({\n        windowMs: env.rateLimitWindow * 1000,\n        limit: (req) => req.rateLimitMax || env.rateLimitMax,\n        standardHeaders: 'draft-6',\n        legacyHeaders: false,\n        keyGenerator: req => req.rateLimitKey || keyGenerator(req),\n        store: await createStore('api'),\n        handler: handleRateExceeded\n    });\n\n    const apiTunnelLimiter = rateLimit({\n        windowMs: env.tunnelRateLimitWindow * 1000,\n        limit: env.tunnelRateLimitMax,\n        standardHeaders: 'draft-6',\n        legacyHeaders: false,\n        keyGenerator: req => keyGenerator(req),\n        store: await createStore('tunnel'),\n        handler: (_, res) => {\n            return res.sendStatus(429);\n        }\n    });\n\n    app.set('trust proxy', ['loopback', 'uniquelocal']);\n\n    app.use('/', cors({\n        methods: ['GET', 'POST'],\n        exposedHeaders: [\n            'Ratelimit-Limit',\n            'Ratelimit-Policy',\n            'Ratelimit-Remaining',\n            'Ratelimit-Reset'\n        ],\n        ...corsConfig,\n    }));\n\n    app.post('/', (req, res, next) => {\n        if (!acceptRegex.test(req.header('Accept'))) {\n            return fail(res, \"error.api.header.accept\");\n        }\n        if (!acceptRegex.test(req.header('Content-Type'))) {\n            return fail(res, \"error.api.header.content_type\");\n        }\n        next();\n    });\n\n    app.post('/', (req, res, next) => {\n        if (!env.apiKeyURL) {\n            return next();\n        }\n\n        const { success, error } = APIKeys.validateAuthorization(req);\n        if (!success) {\n            // We call next() here if either if:\n            // a) we have user sessions enabled, meaning the request\n            //    will still need a Bearer token to not be rejected, or\n            // b) we do not require the user to be authenticated, and\n            //    so they can just make the request with the regular\n            //    rate limit configuration;\n            // otherwise, we reject the request.\n            if (\n                (env.sessionEnabled || !env.authRequired)\n                && ['missing', 'not_api_key'].includes(error)\n            ) {\n                return next();\n            }\n\n            return fail(res, `error.api.auth.key.${error}`);\n        }\n\n        req.authType = \"key\";\n        return next();\n    });\n\n    app.post('/', (req, res, next) => {\n        if (!env.sessionEnabled || req.rateLimitKey) {\n            return next();\n        }\n\n        try {\n            const authorization = req.header(\"Authorization\");\n            if (!authorization) {\n                return fail(res, \"error.api.auth.jwt.missing\");\n            }\n\n            if (authorization.length >= 256) {\n                return fail(res, \"error.api.auth.jwt.invalid\");\n            }\n\n            const [ type, token, ...rest ] = authorization.split(\" \");\n            if (!token || type.toLowerCase() !== 'bearer' || rest.length) {\n                return fail(res, \"error.api.auth.jwt.invalid\");\n            }\n\n            if (!jwt.verify(token, getIP(req, 32))) {\n                return fail(res, \"error.api.auth.jwt.invalid\");\n            }\n\n            req.rateLimitKey = hashHmac(token, 'rate');\n            req.authType = \"session\";\n        } catch {\n            return fail(res, \"error.api.generic\");\n        }\n        next();\n    });\n\n    app.post('/', apiLimiter);\n    app.use('/', express.json({ limit: 1024 }));\n\n    app.use('/', (err, _, res, next) => {\n        if (err) {\n            const { status, body } = createResponse(\"error\", {\n                code: \"error.api.invalid_body\",\n            });\n            return res.status(status).json(body);\n        }\n\n        next();\n    });\n\n    app.post(\"/session\", sessionLimiter, async (req, res) => {\n        if (!env.sessionEnabled) {\n            return fail(res, \"error.api.auth.not_configured\")\n        }\n\n        const turnstileResponse = req.header(\"cf-turnstile-response\");\n\n        if (!turnstileResponse) {\n            return fail(res, \"error.api.auth.turnstile.missing\");\n        }\n\n        const turnstileResult = await verifyTurnstileToken(\n            turnstileResponse,\n            req.ip\n        );\n\n        if (!turnstileResult) {\n            return fail(res, \"error.api.auth.turnstile.invalid\");\n        }\n\n        try {\n            res.json(jwt.generate(getIP(req, 32)));\n        } catch {\n            return fail(res, \"error.api.generic\");\n        }\n    });\n\n    app.post('/', async (req, res) => {\n        const request = req.body;\n\n        if (!request.url) {\n            return fail(res, \"error.api.link.missing\");\n        }\n\n        const { success, data: normalizedRequest } = await normalizeRequest(request);\n        if (!success) {\n            return fail(res, \"error.api.invalid_body\");\n        }\n\n        const parsed = extract(\n            normalizedRequest.url,\n            APIKeys.getAllowedServices(req.rateLimitKey),\n        );\n\n        if (!parsed) {\n            return fail(res, \"error.api.link.invalid\");\n        }\n\n        if (\"error\" in parsed) {\n            let context;\n            if (parsed?.context) {\n                context = parsed.context;\n            }\n            return fail(res, `error.api.${parsed.error}`, context);\n        }\n\n        try {\n            const result = await match({\n                host: parsed.host,\n                patternMatch: parsed.patternMatch,\n                params: normalizedRequest,\n                authType: req.authType ?? \"none\",\n            });\n\n            res.status(result.status).json(result.body);\n        } catch {\n            fail(res, \"error.api.generic\");\n        }\n    });\n\n    app.use('/tunnel', cors({\n        methods: ['GET'],\n        exposedHeaders: [\n            'Estimated-Content-Length',\n            'Content-Disposition'\n        ],\n        ...corsConfig,\n    }));\n\n    app.get('/tunnel', apiTunnelLimiter, async (req, res) => {\n        const id = String(req.query.id);\n        const exp = String(req.query.exp);\n        const sig = String(req.query.sig);\n        const sec = String(req.query.sec);\n        const iv = String(req.query.iv);\n\n        const checkQueries = id && exp && sig && sec && iv;\n        const checkBaseLength = id.length === 21 && exp.length === 13;\n        const checkSafeLength = sig.length === 43 && sec.length === 43 && iv.length === 22;\n\n        if (!checkQueries || !checkBaseLength || !checkSafeLength) {\n            return res.status(400).end();\n        }\n\n        if (req.query.p) {\n            return res.status(200).end();\n        }\n\n        const streamInfo = await verifyStream(id, sig, exp, sec, iv);\n        if (!streamInfo?.service) {\n            return res.status(streamInfo.status).end();\n        }\n\n        if (streamInfo.type === 'proxy') {\n            streamInfo.range = req.headers['range'];\n        }\n\n        return stream(res, streamInfo);\n    });\n\n    app.get('/', (_, res) => {\n        res.type('json');\n        res.status(200).send(env.envFile ? getServerInfo() : serverInfo);\n    })\n\n    app.get('/favicon.ico', (req, res) => {\n        res.status(404).end();\n    })\n\n    app.get('/*', (req, res) => {\n        res.redirect('/');\n    })\n\n    // handle all express errors\n    app.use((_, __, res, ___) => {\n        return fail(res, \"error.api.generic\");\n    })\n\n    randomizeCiphers();\n    setInterval(randomizeCiphers, 1000 * 60 * 30); // shuffle ciphers every 30 minutes\n\n    env.subscribe(['externalProxy', 'httpProxyValues'], () => {\n        // TODO: remove env.externalProxy in a future version\n        const options = {};\n        if (env.externalProxy) {\n            options.httpProxy = env.externalProxy;\n        }\n\n        setGlobalDispatcher(\n            new EnvHttpProxyAgent(options)\n        );\n    });\n\n    http.createServer(app).listen({\n        port: env.apiPort,\n        host: env.listenAddress,\n        reusePort: env.instanceCount > 1 || undefined\n    }, () => {\n        if (isPrimary) {\n            console.log(`\\n` +\n                Bright(Cyan(\"cobalt \")) + Bright(\"API ^ω^\") + \"\\n\" +\n\n                \"~~~~~~\\n\" +\n                Bright(\"version: \") + version + \"\\n\" +\n                Bright(\"commit: \") + git.commit + \"\\n\" +\n                Bright(\"branch: \") + git.branch + \"\\n\" +\n                Bright(\"remote: \") + git.remote + \"\\n\" +\n                Bright(\"start time: \") + startTime.toUTCString() + \"\\n\" +\n                \"~~~~~~\\n\" +\n\n                Bright(\"url: \") + Bright(Cyan(env.apiURL)) + \"\\n\" +\n                Bright(\"port: \") + env.apiPort + \"\\n\"\n            );\n        }\n\n        if (env.apiKeyURL) {\n            APIKeys.setup(env.apiKeyURL);\n        }\n\n        if (env.cookiePath) {\n            Cookies.setup(env.cookiePath);\n        }\n\n        if (env.ytSessionServer) {\n            YouTubeSession.setup();\n        }\n    });\n\n    setupTunnelHandler();\n}\n"
  },
  {
    "path": "api/src/core/env.js",
    "content": "import { Constants } from \"youtubei.js\";\nimport { services } from \"../processing/service-config.js\";\nimport { updateEnv, canonicalEnv, env as currentEnv } from \"../config.js\";\n\nimport { FileWatcher } from \"../misc/file-watcher.js\";\nimport { isURL } from \"../misc/utils.js\";\nimport * as cluster from \"../misc/cluster.js\";\nimport { Green, Yellow } from \"../misc/console-text.js\";\n\nconst forceLocalProcessingOptions = [\"never\", \"session\", \"always\"];\nconst youtubeHlsOptions = [\"never\", \"key\", \"always\"];\n\nconst httpProxyVariables = [\"NO_PROXY\", \"HTTP_PROXY\", \"HTTPS_PROXY\"].flatMap(\n    k => [ k, k.toLowerCase() ]\n);\n\nconst changeCallbacks = {};\n\nconst onEnvChanged = (changes) => {\n    for (const key of changes) {\n        if (changeCallbacks[key]) {\n            changeCallbacks[key].map(fn => {\n                try { fn() } catch {}\n            });\n        }\n    }\n}\n\nconst subscribe = (keys, fn) => {\n    keys = [keys].flat();\n\n    for (const key of keys) {\n        if (key in currentEnv && key !== 'subscribe') {\n            changeCallbacks[key] ??= [];\n            changeCallbacks[key].push(fn);\n            fn();\n        } else throw `invalid env key ${key}`;\n    }\n}\n\nexport const loadEnvs = (env = process.env) => {\n    const allServices = new Set(Object.keys(services));\n    const disabledServices = env.DISABLED_SERVICES?.split(',') || [];\n    const enabledServices = new Set(Object.keys(services).filter(e => {\n        if (!disabledServices.includes(e)) {\n            return e;\n        }\n    }));\n\n    // we need to copy the proxy envs (HTTP_PROXY, HTTPS_PROXY)\n    // back into process.env, so that EnvHttpProxyAgent can pick\n    // them up later\n    for (const key of httpProxyVariables) {\n        const value = env[key] ?? canonicalEnv[key];\n        if (value !== undefined) {\n            process.env[key] = env[key];\n        } else {\n            delete process.env[key];\n        }\n    }\n\n    return {\n        apiURL: env.API_URL || '',\n        apiPort: env.API_PORT || 9000,\n        tunnelPort: env.API_PORT || 9000,\n\n        listenAddress: env.API_LISTEN_ADDRESS,\n        freebindCIDR: process.platform === 'linux' && env.FREEBIND_CIDR,\n\n        corsWildcard: env.CORS_WILDCARD !== '0',\n        corsURL: env.CORS_URL,\n\n        cookiePath: env.COOKIE_PATH,\n\n        rateLimitWindow: (env.RATELIMIT_WINDOW && parseInt(env.RATELIMIT_WINDOW)) || 60,\n        rateLimitMax: (env.RATELIMIT_MAX && parseInt(env.RATELIMIT_MAX)) || 20,\n\n        tunnelRateLimitWindow: (env.TUNNEL_RATELIMIT_WINDOW && parseInt(env.TUNNEL_RATELIMIT_WINDOW)) || 60,\n        tunnelRateLimitMax: (env.TUNNEL_RATELIMIT_MAX && parseInt(env.TUNNEL_RATELIMIT_MAX)) || 40,\n\n        sessionRateLimitWindow: (env.SESSION_RATELIMIT_WINDOW && parseInt(env.SESSION_RATELIMIT_WINDOW)) || 60,\n        sessionRateLimit:\n            // backwards compatibility with SESSION_RATELIMIT\n            // till next major due to an error in docs\n            (env.SESSION_RATELIMIT_MAX && parseInt(env.SESSION_RATELIMIT_MAX))\n            || (env.SESSION_RATELIMIT && parseInt(env.SESSION_RATELIMIT))\n            || 10,\n\n        durationLimit: (env.DURATION_LIMIT && parseInt(env.DURATION_LIMIT)) || 10800,\n        streamLifespan: (env.TUNNEL_LIFESPAN && parseInt(env.TUNNEL_LIFESPAN)) || 90,\n\n        processingPriority: process.platform !== 'win32'\n            && env.PROCESSING_PRIORITY\n            && parseInt(env.PROCESSING_PRIORITY),\n\n        externalProxy: env.API_EXTERNAL_PROXY,\n\n        // used only for comparing against old values when envs are being updated\n        httpProxyValues: httpProxyVariables.map(k => String(env[k])).join(''),\n\n        turnstileSitekey: env.TURNSTILE_SITEKEY,\n        turnstileSecret: env.TURNSTILE_SECRET,\n        jwtSecret: env.JWT_SECRET,\n        jwtLifetime: env.JWT_EXPIRY || 120,\n\n        sessionEnabled: env.TURNSTILE_SITEKEY\n                            && env.TURNSTILE_SECRET\n                            && env.JWT_SECRET,\n\n        apiKeyURL: env.API_KEY_URL && new URL(env.API_KEY_URL),\n        authRequired: env.API_AUTH_REQUIRED === '1',\n        redisURL: env.API_REDIS_URL,\n        instanceCount: (env.API_INSTANCE_COUNT && parseInt(env.API_INSTANCE_COUNT)) || 1,\n        keyReloadInterval: 900,\n\n        allServices,\n        enabledServices,\n\n        customInnertubeClient: env.CUSTOM_INNERTUBE_CLIENT,\n        ytSessionServer: env.YOUTUBE_SESSION_SERVER,\n        ytSessionReloadInterval: 300,\n        ytSessionInnertubeClient: env.YOUTUBE_SESSION_INNERTUBE_CLIENT,\n        ytAllowBetterAudio: env.YOUTUBE_ALLOW_BETTER_AUDIO !== \"0\",\n\n        // \"never\" | \"session\" | \"always\"\n        forceLocalProcessing: env.FORCE_LOCAL_PROCESSING ?? \"never\",\n\n        // \"never\" | \"key\" | \"always\"\n        enableDeprecatedYoutubeHls: env.ENABLE_DEPRECATED_YOUTUBE_HLS ?? \"never\",\n\n        envFile: env.API_ENV_FILE,\n        envRemoteReloadInterval: 300,\n\n        subscribe,\n    };\n}\n\nlet loggedProxyWarning = false;\n\nexport const validateEnvs = async (env) => {\n    if (env.sessionEnabled && env.jwtSecret.length < 16) {\n        throw new Error(\"JWT_SECRET env is too short (must be at least 16 characters long)\");\n    }\n\n    if (env.instanceCount > 1 && !env.redisURL) {\n        throw new Error(\"API_REDIS_URL is required when API_INSTANCE_COUNT is >= 2\");\n    } else if (env.instanceCount > 1 && !await cluster.supportsReusePort()) {\n        console.error('API_INSTANCE_COUNT is not supported in your environment. to use this env, your node.js');\n        console.error('version must be >= 23.1.0, and you must be running a recent enough version of linux');\n        console.error('(or other OS that supports it). for more info, see `reusePort` option on');\n        console.error('https://nodejs.org/api/net.html#serverlistenoptions-callback');\n        throw new Error('SO_REUSEPORT is not supported');\n    }\n\n    if (env.customInnertubeClient && !Constants.SUPPORTED_CLIENTS.includes(env.customInnertubeClient)) {\n        console.error(\"CUSTOM_INNERTUBE_CLIENT is invalid. Provided client is not supported.\");\n        console.error(`Supported clients are: ${Constants.SUPPORTED_CLIENTS.join(', ')}\\n`);\n        throw new Error(\"Invalid CUSTOM_INNERTUBE_CLIENT\");\n    }\n\n    if (env.forceLocalProcessing && !forceLocalProcessingOptions.includes(env.forceLocalProcessing)) {\n        console.error(\"FORCE_LOCAL_PROCESSING is invalid.\");\n        console.error(`Supported options are are: ${forceLocalProcessingOptions.join(', ')}\\n`);\n        throw new Error(\"Invalid FORCE_LOCAL_PROCESSING\");\n    }\n\n    if (env.enableDeprecatedYoutubeHls && !youtubeHlsOptions.includes(env.enableDeprecatedYoutubeHls)) {\n        console.error(\"ENABLE_DEPRECATED_YOUTUBE_HLS is invalid.\");\n        console.error(`Supported options are are: ${youtubeHlsOptions.join(', ')}\\n`);\n        throw new Error(\"Invalid ENABLE_DEPRECATED_YOUTUBE_HLS\");\n    }\n\n    if (env.externalProxy && env.freebindCIDR) {\n        throw new Error('freebind is not available when external proxy is enabled')\n    }\n\n    if (env.externalProxy && !loggedProxyWarning) {\n        console.error('API_EXTERNAL_PROXY is deprecated and will be removed in a future release.');\n        console.error('Use HTTP_PROXY or HTTPS_PROXY instead.');\n        console.error('You can read more about the new proxy variables in docs/api-env-variables.md\\n');\n\n        // prevent the warning from being printed on every env validation\n        loggedProxyWarning = true;\n    }\n\n    return env;\n}\n\nconst reloadEnvs = async (contents) => {\n    const newEnvs = {};\n    const resolvedContents = await contents;\n\n    for (let line of resolvedContents.split('\\n')) {\n        line = line.trim();\n        if (line === '') {\n            continue;\n        }\n\n        let [ key, value ] = line.split(/=(.+)?/);\n        if (key) {\n            if (value.match(/^['\"]/) && value.match(/['\"]$/)) {\n                value = JSON.parse(value);\n            }\n\n            newEnvs[key] = value || '';\n        }\n    }\n\n    const candidate = {\n        ...canonicalEnv,\n        ...newEnvs,\n    };\n\n    const parsed = await validateEnvs(\n        loadEnvs(candidate)\n    );\n\n    cluster.broadcast({ env_update: resolvedContents });\n    return updateEnv(parsed);\n}\n\nconst wrapReload = (contents) => {\n    reloadEnvs(contents)\n    .then(changes => {\n        if (changes.length === 0) {\n            return;\n        }\n\n        onEnvChanged(changes);\n\n        console.log(`${Green('[✓]')} envs reloaded successfully!`);\n        for (const key of changes) {\n            const value = currentEnv[key];\n            const isSecret = key.toLowerCase().includes('apikey')\n                          || key.toLowerCase().includes('secret')\n                          || key === 'httpProxyValues';\n\n            if (!value) {\n                console.log(`    removed: ${key}`);\n            } else {\n                console.log(`    changed: ${key} -> ${isSecret ? '***' : value}`);\n            }\n        }\n    })\n    .catch((e) => {\n        console.error(`${Yellow('[!]')} Failed reloading environment variables at ${new Date().toISOString()}.`);\n        console.error('Error:', e);\n    });\n}\n\nlet watcher;\nconst setupWatcherFromFile = (path) => {\n    const load = () => wrapReload(watcher.read());\n\n    if (isURL(path)) {\n        watcher = FileWatcher.fromFileProtocol(path);\n    } else {\n        watcher = new FileWatcher({ path });\n    }\n\n    watcher.on('file-updated', load);\n    load();\n}\n\nconst setupWatcherFromFetch = (url) => {\n    const load = () => wrapReload(fetch(url).then(r => r.text()));\n    setInterval(load, currentEnv.envRemoteReloadInterval);\n    load();\n}\n\nexport const setupEnvWatcher = () => {\n    if (cluster.isPrimary) {\n        const envFile = currentEnv.envFile;\n        const isFile = !isURL(envFile)\n                       || new URL(envFile).protocol === 'file:';\n\n        if (isFile) {\n            setupWatcherFromFile(envFile);\n        } else {\n            setupWatcherFromFetch(envFile);\n        }\n    } else if (cluster.isWorker) {\n        process.on('message', (message) => {\n            if ('env_update' in message) {\n                reloadEnvs(message.env_update);\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "api/src/core/itunnel.js",
    "content": "import stream from \"../stream/stream.js\";\nimport { getInternalTunnel } from \"../stream/manage.js\";\nimport { setTunnelPort } from \"../config.js\";\nimport { Green } from \"../misc/console-text.js\";\nimport express from \"express\";\n\nconst validateTunnel = (req, res) => {\n    if (!req.ip.endsWith('127.0.0.1')) {\n        res.sendStatus(403);\n        return;\n    }\n\n    if (String(req.query.id).length !== 21) {\n        res.sendStatus(400);\n        return;\n    }\n\n    const streamInfo = getInternalTunnel(req.query.id);\n    if (!streamInfo) {\n        res.sendStatus(404);\n        return;\n    }\n\n    return streamInfo;\n}\n\nconst streamTunnel = (req, res) => {\n    const streamInfo = validateTunnel(req, res);\n    if (!streamInfo) {\n        return;\n    }\n\n    streamInfo.headers = new Map([\n        ...(streamInfo.headers || []),\n        ...Object.entries(req.headers)\n    ]);\n\n    return stream(res, { type: 'internal', data: streamInfo });\n}\n\nexport const setupTunnelHandler = () => {\n    const tunnelHandler = express();\n\n    tunnelHandler.get('/itunnel', streamTunnel);\n\n    // fallback\n    tunnelHandler.use((_, res) => res.sendStatus(400));\n    // error handler\n    tunnelHandler.use((_, __, res, ____) => res.socket.end());\n\n\n    const server = tunnelHandler.listen({\n        port: 0,\n        host: '127.0.0.1',\n        exclusive: true\n    }, () => {\n        const { port } = server.address();\n        console.log(`${Green('[✓]')} internal tunnel handler running on 127.0.0.1:${port}`);\n        setTunnelPort(port);\n    });\n}\n"
  },
  {
    "path": "api/src/misc/cluster.js",
    "content": "import cluster from \"node:cluster\";\nimport net from \"node:net\";\nimport { syncSecrets } from \"../security/secrets.js\";\nimport { env, isCluster } from \"../config.js\";\n\nexport { isPrimary, isWorker } from \"node:cluster\";\n\nexport const supportsReusePort = async () => {\n    try {\n        await new Promise((resolve, reject) => {\n            const server = net.createServer().listen({ port: 0, reusePort: true });\n            server.on('listening', () => server.close(resolve));\n            server.on('error', (err) => (server.close(), reject(err)));\n        });\n\n        const [major, minor] = process.versions.node.split('.').map(Number);\n        return major > 23 || (major === 23 && minor >= 1);\n    } catch {\n        return false;\n    }\n}\n\nexport const initCluster = async () => {\n    if (cluster.isPrimary) {\n        for (let i = 1; i < env.instanceCount; ++i) {\n            cluster.fork();\n        }\n    }\n\n    await syncSecrets();\n}\n\nexport const broadcast = (message) => {\n    if (!isCluster || !cluster.isPrimary || !cluster.workers) {\n        return;\n    }\n\n    for (const worker of Object.values(cluster.workers)) {\n        worker.send(message);\n    }\n}\n\nexport const send = (message) => {\n    if (!isCluster) {\n        return;\n    }\n\n    if (cluster.isPrimary) {\n        return broadcast(message);\n    } else {\n        return process.send(message);\n    }\n}\n\nexport const waitFor = (key) => {\n    return new Promise(resolve => {\n        const listener = (message) => {\n            if (key in message) {\n                process.off('message', listener);\n                return resolve(message);\n            }\n        }\n\n        process.on('message', listener);\n    });\n}\n\nexport const mainOnMessage = (cb) => {\n    for (const worker of Object.values(cluster.workers)) {\n        worker.on('message', cb);\n    }\n}\n"
  },
  {
    "path": "api/src/misc/console-text.js",
    "content": "const ANSI = {\n    RESET: \"\\x1b[0m\",\n    BRIGHT: \"\\x1b[1m\",\n    RED: \"\\x1b[31m\",\n    GREEN: \"\\x1b[32m\",\n    CYAN: \"\\x1b[36m\",\n    YELLOW: \"\\x1b[93m\"\n}\n\nfunction wrap(color, text) {\n    if (!ANSI[color.toUpperCase()]) {\n        throw \"invalid color\";\n    }\n\n    return ANSI[color.toUpperCase()] + text + ANSI.RESET;\n}\n\nexport function Bright(text) {\n    return wrap('bright', text);\n}\n\nexport function Red(text) {\n    return wrap('red', text);\n}\n\nexport function Green(text) {\n    return wrap('green', text);\n}\n\nexport function Cyan(text) {\n    return wrap('cyan', text);\n}\n\nexport function Yellow(text) {\n    return wrap('yellow', text);\n}\n"
  },
  {
    "path": "api/src/misc/crypto.js",
    "content": "import { createCipheriv, createDecipheriv } from \"crypto\";\n\nconst algorithm = \"aes256\";\n\nexport function encryptStream(plaintext, iv, secret) {\n    const buff = Buffer.from(JSON.stringify(plaintext));\n    const key = Buffer.from(secret, \"base64url\");\n    const cipher = createCipheriv(algorithm, key, Buffer.from(iv, \"base64url\"));\n\n    return Buffer.concat([ cipher.update(buff), cipher.final() ])\n}\n\nexport function decryptStream(ciphertext, iv, secret) {\n    const buff = Buffer.from(ciphertext);\n    const key = Buffer.from(secret, \"base64url\");\n    const decipher = createDecipheriv(algorithm, key, Buffer.from(iv, \"base64url\"));\n\n    return Buffer.concat([ decipher.update(buff), decipher.final() ])\n}\n"
  },
  {
    "path": "api/src/misc/file-watcher.js",
    "content": "import { EventEmitter } from 'node:events';\nimport * as fs from 'node:fs/promises';\n\nexport class FileWatcher extends EventEmitter {\n    #path;\n    #hasWatcher = false;\n    #lastChange = new Date().getTime();\n\n    constructor({ path, ...rest }) {\n        super(rest);\n        this.#path = path;\n    }\n\n    async #setupWatcher() {\n        if (this.#hasWatcher)\n            return;\n\n        this.#hasWatcher = true;\n        const watcher = fs.watch(this.#path);\n        for await (const _ of watcher) {\n            if (new Date() - this.#lastChange > 50) {\n                this.emit('file-updated');\n                this.#lastChange = new Date().getTime();\n            }\n        }\n    }\n\n    read() {\n        this.#setupWatcher();\n        return fs.readFile(this.#path, 'utf8');\n    }\n\n    static fromFileProtocol(url_) {\n        const url = new URL(url_);\n        if (url.protocol !== 'file:') {\n            return;\n        }\n\n        const pathname = url.pathname === '/' ? '' : url.pathname;\n        const file_path = decodeURIComponent(url.host + pathname);\n        return new this({ path: file_path });\n    }\n}\n"
  },
  {
    "path": "api/src/misc/language-codes.js",
    "content": "// converted from this file https://www.loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt\nconst iso639_1to2 = {\n    'aa': 'aar', 'ab': 'abk', 'af': 'afr', 'ak': 'aka', 'sq': 'sqi',\n    'am': 'amh', 'ar': 'ara', 'an': 'arg', 'hy': 'hye', 'as': 'asm',\n    'av': 'ava', 'ae': 'ave', 'ay': 'aym', 'az': 'aze', 'ba': 'bak',\n    'bm': 'bam', 'eu': 'eus', 'be': 'bel', 'bn': 'ben', 'bi': 'bis',\n    'bs': 'bos', 'br': 'bre', 'bg': 'bul', 'my': 'mya', 'ca': 'cat',\n    'ch': 'cha', 'ce': 'che', 'zh': 'zho', 'cu': 'chu', 'cv': 'chv',\n    'kw': 'cor', 'co': 'cos', 'cr': 'cre', 'cs': 'ces', 'da': 'dan',\n    'dv': 'div', 'nl': 'nld', 'dz': 'dzo', 'en': 'eng', 'eo': 'epo',\n    'et': 'est', 'ee': 'ewe', 'fo': 'fao', 'fj': 'fij', 'fi': 'fin',\n    'fr': 'fra', 'fy': 'fry', 'ff': 'ful', 'ka': 'kat', 'de': 'deu',\n    'gd': 'gla', 'ga': 'gle', 'gl': 'glg', 'gv': 'glv', 'el': 'ell',\n    'gn': 'grn', 'gu': 'guj', 'ht': 'hat', 'ha': 'hau', 'he': 'heb',\n    'hz': 'her', 'hi': 'hin', 'ho': 'hmo', 'hr': 'hrv', 'hu': 'hun',\n    'ig': 'ibo', 'is': 'isl', 'io': 'ido', 'ii': 'iii', 'iu': 'iku',\n    'ie': 'ile', 'ia': 'ina', 'id': 'ind', 'ik': 'ipk', 'it': 'ita',\n    'jv': 'jav', 'ja': 'jpn', 'kl': 'kal', 'kn': 'kan', 'ks': 'kas',\n    'kr': 'kau', 'kk': 'kaz', 'km': 'khm', 'ki': 'kik', 'rw': 'kin',\n    'ky': 'kir', 'kv': 'kom', 'kg': 'kon', 'ko': 'kor', 'kj': 'kua',\n    'ku': 'kur', 'lo': 'lao', 'la': 'lat', 'lv': 'lav', 'li': 'lim',\n    'ln': 'lin', 'lt': 'lit', 'lb': 'ltz', 'lu': 'lub', 'lg': 'lug',\n    'mk': 'mkd', 'mh': 'mah', 'ml': 'mal', 'mi': 'mri', 'mr': 'mar',\n    'ms': 'msa', 'mg': 'mlg', 'mt': 'mlt', 'mn': 'mon', 'na': 'nau',\n    'nv': 'nav', 'nr': 'nbl', 'nd': 'nde', 'ng': 'ndo', 'ne': 'nep',\n    'nn': 'nno', 'nb': 'nob', 'no': 'nor', 'ny': 'nya', 'oc': 'oci',\n    'oj': 'oji', 'or': 'ori', 'om': 'orm', 'os': 'oss', 'pa': 'pan',\n    'fa': 'fas', 'pi': 'pli', 'pl': 'pol', 'pt': 'por', 'ps': 'pus',\n    'qu': 'que', 'rm': 'roh', 'ro': 'ron', 'rn': 'run', 'ru': 'rus',\n    'sg': 'sag', 'sa': 'san', 'si': 'sin', 'sk': 'slk', 'sl': 'slv',\n    'se': 'sme', 'sm': 'smo', 'sn': 'sna', 'sd': 'snd', 'so': 'som',\n    'st': 'sot', 'es': 'spa', 'sc': 'srd', 'sr': 'srp', 'ss': 'ssw',\n    'su': 'sun', 'sw': 'swa', 'sv': 'swe', 'ty': 'tah', 'ta': 'tam',\n    'tt': 'tat', 'te': 'tel', 'tg': 'tgk', 'tl': 'tgl', 'th': 'tha',\n    'bo': 'bod', 'ti': 'tir', 'to': 'ton', 'tn': 'tsn', 'ts': 'tso',\n    'tk': 'tuk', 'tr': 'tur', 'tw': 'twi', 'ug': 'uig', 'uk': 'ukr',\n    'ur': 'urd', 'uz': 'uzb', 've': 'ven', 'vi': 'vie', 'vo': 'vol',\n    'cy': 'cym', 'wa': 'wln', 'wo': 'wol', 'xh': 'xho', 'yi': 'yid',\n    'yo': 'yor', 'za': 'zha', 'zu': 'zul',\n}\n\nconst iso639_2to1 = Object.fromEntries(\n    Object.entries(iso639_1to2).map(([k, v]) => [v, k])\n);\n\nconst maps = {\n    2: iso639_1to2,\n    3: iso639_2to1,\n}\n\nexport const convertLanguageCode = (code) => {\n    code = code?.split(\"-\")[0]?.split(\"_\")[0] || \"\";\n    return maps[code.length]?.[code.toLowerCase()] || null;\n}\n"
  },
  {
    "path": "api/src/misc/load-from-fs.js",
    "content": "import * as fs from \"fs\";\nimport { join, dirname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst root = join(\n    dirname(fileURLToPath(import.meta.url)),\n    '../../'\n);\n\nexport function loadFile(path) {\n    return fs.readFileSync(join(root, path), 'utf-8')\n}\n\nexport function loadJSON(path) {\n    try {\n        return JSON.parse(loadFile(path))\n    } catch {\n        return false\n    }\n}\n"
  },
  {
    "path": "api/src/misc/randomize-ciphers.js",
    "content": "import tls from 'node:tls';\nimport { randomBytes } from 'node:crypto';\n\nconst ORIGINAL_CIPHERS = tls.DEFAULT_CIPHERS;\n\n// How many ciphers from the top of the list to shuffle.\n// The remaining ciphers are left in the original order.\nconst TOP_N_SHUFFLE = 8;\n\n// Modified variation of https://stackoverflow.com/a/12646864\nconst shuffleArray = (array) => {\n    for (let i = array.length - 1; i > 0; i--) {\n        const j = randomBytes(4).readUint32LE() % array.length;\n        [array[i], array[j]] = [array[j], array[i]];\n    }\n\n    return array;\n}\n\nexport const randomizeCiphers = () => {\n    do {\n        const cipherList = ORIGINAL_CIPHERS.split(':');\n        const shuffled = shuffleArray(cipherList.slice(0, TOP_N_SHUFFLE));\n        const retained = cipherList.slice(TOP_N_SHUFFLE);\n\n        tls.DEFAULT_CIPHERS = [ ...shuffled, ...retained ].join(':');\n    } while (tls.DEFAULT_CIPHERS === ORIGINAL_CIPHERS);\n}\n"
  },
  {
    "path": "api/src/misc/run-test.js",
    "content": "import { normalizeRequest } from \"../processing/request.js\";\nimport match from \"../processing/match.js\";\nimport { extract } from \"../processing/url.js\";\n\nexport async function runTest(url, params, expect) {\n    const { success, data: normalized } = await normalizeRequest({ url, ...params });\n    if (!success) {\n        throw \"invalid request\";\n    }\n\n    const parsed = extract(normalized.url);\n    if (parsed === null) {\n        throw `invalid url: ${normalized.url}`;\n    }\n\n    const result = await match({\n        host: parsed.host,\n        patternMatch: parsed.patternMatch,\n        params: normalized,\n    });\n\n    let error = [];\n    if (expect.status !== result.body.status) {\n        const detail = `${expect.status} (expected) != ${result.body.status} (actual)`;\n        error.push(`status mismatch: ${detail}`);\n\n        if (result.body.status === 'error') {\n            error.push(`error code: ${result.body?.error?.code}`);\n        }\n    }\n\n    if (expect.errorCode && expect.errorCode !== result.body?.error?.code) {\n        const detail = `${expect.errorCode} (expected) != ${result.body.error.code} (actual)`\n        error.push(`error mismatch: ${detail}`);\n    }\n\n    if (expect.code !== result.status) {\n        const detail = `${expect.code} (expected) != ${result.status} (actual)`;\n        error.push(`status code mismatch: ${detail}`);\n    }\n\n    if (error.length) {\n        if (result.body.text) {\n            error.push(`error message: ${result.body.text}`);\n        }\n\n        throw error.join('\\n');\n    }\n\n    if (result.body.status === 'tunnel') {\n        // TODO: stream testing\n    }\n}\n"
  },
  {
    "path": "api/src/misc/utils.js",
    "content": "import { request } from \"undici\";\nconst redirectStatuses = new Set([301, 302, 303, 307, 308]);\n\nexport async function getRedirectingURL(url, dispatcher, headers) {\n    const params = {\n        dispatcher,\n        method: 'HEAD',\n        headers,\n        redirect: 'manual'\n    };\n    const getParams = {\n        ...params,\n        method: 'GET',\n    };\n\n    const callback = (r) => {\n        if (redirectStatuses.has(r.statusCode) && r.headers['location']) {\n            return r.headers['location'];\n        }\n    }\n\n    /*\n        try request() with HEAD & GET,\n        then do the same with fetch\n        (fetch is required for shortened reddit links)\n    */\n\n    let location = await request(url, params)\n        .then(callback).catch(() => null);\n\n    location ??= await request(url, getParams)\n        .then(callback).catch(() => null);\n\n    location ??= await fetch(url, params)\n        .then(callback).catch(() => null);\n\n    location ??= await fetch(url, getParams)\n        .then(callback).catch(() => null);\n\n    return location;\n}\n\nexport function merge(a, b) {\n    for (const k of Object.keys(b)) {\n        if (Array.isArray(b[k])) {\n            a[k] = [...(a[k] ?? []), ...b[k]];\n        } else if (typeof b[k] === 'object') {\n            a[k] = merge(a[k], b[k]);\n        } else {\n            a[k] = b[k];\n        }\n    }\n\n    return a;\n}\n\nexport function splitFilenameExtension(filename) {\n    const parts = filename.split('.');\n    const ext = parts.pop();\n\n    if (!parts.length) {\n        return [ ext, \"\" ]\n    } else {\n        return [ parts.join('.'), ext ]\n    }\n}\n\nexport function zip(a, b) {\n    return a.map((value, i) => [ value, b[i] ]);\n}\n\nexport function isURL(input) {\n    try {\n        new URL(input);\n        return true;\n    } catch {\n        return false;\n    }\n}\n"
  },
  {
    "path": "api/src/processing/cookie/cookie.js",
    "content": "import { strict as assert } from 'node:assert';\n\nexport default class Cookie {\n    constructor(input) {\n        assert(typeof input === 'object');\n        this._values = {};\n\n        for (const [ k, v ] of Object.entries(input))\n            this.set(k, v);\n    }\n\n    set(key, value) {\n        const old = this._values[key];\n        if (old === value)\n            return false;\n\n        this._values[key] = value;\n        return true;\n    }\n\n    unset(keys) {\n        for (const key of keys) delete this._values[key]\n    }\n\n    static fromString(str) {\n        const obj = {};\n\n        str.split('; ').forEach(cookie => {\n            const key = cookie.split('=')[0];\n            const value = cookie.split('=').splice(1).join('=');\n            obj[key] = value\n        })\n\n        return new Cookie(obj)\n    }\n\n    toString() {\n        return Object.entries(this._values).map(([ name, value ]) => `${name}=${value}`).join('; ')\n    }\n\n    toJSON() {\n        return this.toString()\n    }\n\n    values() {\n        return Object.freeze({ ...this._values })\n    }\n}\n"
  },
  {
    "path": "api/src/processing/cookie/manager.js",
    "content": "import Cookie from './cookie.js';\n\nimport { readFile, writeFile } from 'fs/promises';\nimport { Red, Green, Yellow } from '../../misc/console-text.js';\nimport { parse as parseSetCookie, splitCookiesString } from 'set-cookie-parser';\nimport * as cluster from '../../misc/cluster.js';\nimport { isCluster } from '../../config.js';\n\nconst WRITE_INTERVAL = 60000;\nconst VALID_SERVICES = new Set([\n    'instagram',\n    'instagram_bearer',\n    'reddit',\n    'twitter',\n    'youtube',\n    'vimeo_bearer',\n]);\n\nconst invalidCookies = {};\nlet cookies = {}, dirty = false, intervalId;\n\nfunction writeChanges(cookiePath) {\n    if (!dirty) return;\n    dirty = false;\n\n    const cookieData = JSON.stringify({ ...cookies, ...invalidCookies }, null, 4);\n    writeFile(cookiePath, cookieData).catch((e) => {\n        console.warn(`${Yellow('[!]')} failed writing updated cookies to storage`);\n        console.warn(e);\n        clearInterval(intervalId);\n        intervalId = null;\n    })\n}\n\nconst setupMain = async (cookiePath) => {\n    try {\n        cookies = await readFile(cookiePath, 'utf8');\n        cookies = JSON.parse(cookies);\n        for (const serviceName in cookies) {\n            if (!VALID_SERVICES.has(serviceName)) {\n                console.warn(`${Yellow('[!]')} ignoring unknown service in cookie file: ${serviceName}`);\n            } else if (!Array.isArray(cookies[serviceName])) {\n                console.warn(`${Yellow('[!]')} ${serviceName} in cookies file is not an array, ignoring it`);\n            } else if (cookies[serviceName].some(c => typeof c !== 'string')) {\n                console.warn(`${Yellow('[!]')} some cookie for ${serviceName} contains non-string value in cookies file`);\n            } else continue;\n\n            invalidCookies[serviceName] = cookies[serviceName];\n            delete cookies[serviceName];\n        }\n\n        if (!intervalId) {\n            intervalId = setInterval(() => writeChanges(cookiePath), WRITE_INTERVAL);\n        }\n\n        cluster.broadcast({ cookies });\n\n        console.log(`${Green('[✓]')} cookies loaded successfully!`);\n    } catch (e) {\n        console.error(`${Yellow('[!]')} failed to load cookies.`);\n        console.error('error:', e);\n    }\n}\n\nconst setupWorker = async () => {\n    cookies = (await cluster.waitFor('cookies')).cookies;\n}\n\nexport const loadFromFile = async (path) => {\n    if (cluster.isPrimary) {\n        await setupMain(path);\n    } else if (cluster.isWorker) {\n        await setupWorker();\n    }\n\n    dirty = false;\n}\n\nexport const setup = async (path) => {\n    await loadFromFile(path);\n\n    if (isCluster) {\n        const messageHandler = (message) => {\n            if ('cookieUpdate' in message) {\n                const { cookieUpdate } = message;\n\n                if (cluster.isPrimary) {\n                    dirty = true;\n                    cluster.broadcast({ cookieUpdate });\n                }\n\n                const { service, idx, cookie } = cookieUpdate;\n                cookies[service][idx] = cookie;\n            }\n        }\n\n        if (cluster.isPrimary) {\n            cluster.mainOnMessage(messageHandler);\n        } else {\n            process.on('message', messageHandler);\n        }\n    }\n}\n\nexport function getCookie(service) {\n    if (!VALID_SERVICES.has(service)) {\n        console.error(\n            `${Red('[!]')} ${service} not in allowed services list for cookies.`\n            + ' if adding a new cookie type, include it there.'\n        );\n        return;\n    }\n\n    if (!cookies[service] || !cookies[service].length) return;\n\n    const idx = Math.floor(Math.random() * cookies[service].length);\n\n    const cookie = cookies[service][idx];\n    if (typeof cookie === 'string') {\n        cookies[service][idx] = Cookie.fromString(cookie);\n    }\n\n    cookies[service][idx].meta = { service, idx };\n    return cookies[service][idx];\n}\n\nexport function updateCookieValues(cookie, values) {\n    let changed = false;\n\n    for (const [ key, value ] of Object.entries(values)) {\n        changed = cookie.set(key, value) || changed;\n    }\n\n    if (changed && cookie.meta) {\n        dirty = true;\n        if (isCluster) {\n            const message = { cookieUpdate: { ...cookie.meta, cookie } };\n            cluster.send(message);\n        }\n    }\n\n    return changed;\n}\n\nexport function updateCookie(cookie, headers) {\n    if (!cookie) return;\n\n    const parsed = parseSetCookie(\n        splitCookiesString(headers.get('set-cookie')),\n        { decodeValues: false }\n    ), values = {}\n\n    cookie.unset(parsed.filter(c => c.expires < new Date()).map(c => c.name));\n    parsed.filter(c => !c.expires || c.expires > new Date()).forEach(c => values[c.name] = c.value);\n\n    updateCookieValues(cookie, values);\n}\n"
  },
  {
    "path": "api/src/processing/create-filename.js",
    "content": "// characters that are disallowed on windows:\n// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions\nconst characterMap = {\n    '<':  '＜',\n    '>':  '＞',\n    ':':  '：',\n    '\"':  '＂',\n    '/':  '／',\n    '\\\\': '＼',\n    '|': '｜',\n    '?': '？',\n    '*': '＊'\n};\n\nexport const sanitizeString = (string) => {\n    // remove any potential control characters the string might contain\n    string = string.replace(/[\\u0000-\\u001F\\u007F-\\u009F]/g, \"\");\n\n    for (const [ char, replacement ] of Object.entries(characterMap)) {\n        string = string.replaceAll(char, replacement);\n    }\n\n    return string;\n}\n\nexport default (f, style, isAudioOnly, isAudioMuted) => {\n    let filename = '';\n\n    let infoBase = [f.service, f.id];\n    let classicTags = [...infoBase];\n    let basicTags = [];\n\n    let title = sanitizeString(f.title);\n\n    if (f.author) {\n        title += ` - ${sanitizeString(f.author)}`;\n    }\n\n    if (f.resolution) {\n        classicTags.push(f.resolution);\n    }\n\n    if (f.qualityLabel) {\n        basicTags.push(f.qualityLabel);\n    }\n\n    if (f.youtubeFormat) {\n        classicTags.push(f.youtubeFormat);\n        basicTags.push(f.youtubeFormat);\n    }\n\n    if (isAudioMuted) {\n        classicTags.push(\"mute\");\n        basicTags.push(\"mute\");\n    } else if (f.youtubeDubName) {\n        classicTags.push(f.youtubeDubName);\n        basicTags.push(f.youtubeDubName);\n    }\n\n    switch (style) {\n        default:\n        case \"classic\":\n            if (isAudioOnly) {\n                if (f.youtubeDubName) {\n                    infoBase.push(f.youtubeDubName);\n                }\n                return `${infoBase.join(\"_\")}_audio`;\n            }\n            filename = classicTags.join(\"_\");\n            break;\n        case \"basic\":\n            if (isAudioOnly) return title;\n            filename = `${title} (${basicTags.join(\", \")})`;\n            break;\n        case \"pretty\":\n            if (isAudioOnly) return `${title} (${infoBase[0]})`;\n            filename = `${title} (${[...basicTags, infoBase[0]].join(\", \")})`;\n            break;\n        case \"nerdy\":\n            if (isAudioOnly) return `${title} (${infoBase.join(\", \")})`;\n            filename = `${title} (${basicTags.concat(infoBase).join(\", \")})`;\n            break;\n    }\n    return `${filename}.${f.extension}`;\n}\n"
  },
  {
    "path": "api/src/processing/helpers/youtube-session.js",
    "content": "import * as cluster from \"../../misc/cluster.js\";\n\nimport { Agent } from \"undici\";\nimport { env } from \"../../config.js\";\nimport { Green, Yellow } from \"../../misc/console-text.js\";\n\nconst defaultAgent = new Agent();\n\nlet session;\n\nconst validateSession = (sessionResponse) => {\n    if (!sessionResponse.potoken) {\n        throw \"no poToken in session response\";\n    }\n\n    if (!sessionResponse.visitor_data) {\n        throw \"no visitor_data in session response\";\n    }\n\n    if (!sessionResponse.updated) {\n        throw \"no last update timestamp in session response\";\n    }\n\n    // https://github.com/iv-org/youtube-trusted-session-generator/blob/c2dfe3f/potoken_generator/main.py#L25\n    if (sessionResponse.potoken.length < 160) {\n        console.error(`${Yellow('[!]')} poToken is too short and might not work (${new Date().toISOString()})`);\n    }\n}\n\nconst updateSession = (newSession) => {\n    session = newSession;\n}\n\nconst loadSession = async () => {\n    const sessionServerUrl = new URL(env.ytSessionServer);\n    sessionServerUrl.pathname = \"/token\";\n\n    const newSession = await fetch(\n        sessionServerUrl,\n        { dispatcher: defaultAgent }\n    ).then(a => a.json());\n\n    validateSession(newSession);\n\n    if (!session || session.updated < newSession?.updated) {\n        cluster.broadcast({ youtube_session: newSession });\n        updateSession(newSession);\n    }\n}\n\nconst wrapLoad = (initial = false) => {\n    loadSession()\n    .then(() => {\n        if (initial) {\n            console.log(`${Green('[✓]')} poToken & visitor_data loaded successfully!`);\n        }\n    })\n    .catch((e) => {\n        console.error(`${Yellow('[!]')} Failed loading poToken & visitor_data at ${new Date().toISOString()}.`);\n        console.error('Error:', e);\n    })\n}\n\nexport const getYouTubeSession = () => {\n    return session;\n}\n\nexport const setup = () => {\n    if (cluster.isPrimary) {\n        wrapLoad(true);\n        if (env.ytSessionReloadInterval > 0) {\n            setInterval(wrapLoad, env.ytSessionReloadInterval * 1000);\n        }\n    } else if (cluster.isWorker) {\n        process.on('message', (message) => {\n            if ('youtube_session' in message) {\n                updateSession(message.youtube_session);\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "api/src/processing/match-action.js",
    "content": "import createFilename from \"./create-filename.js\";\n\nimport { createResponse } from \"./request.js\";\nimport { audioIgnore } from \"./service-config.js\";\nimport { createStream } from \"../stream/manage.js\";\nimport { splitFilenameExtension } from \"../misc/utils.js\";\nimport { convertLanguageCode } from \"../misc/language-codes.js\";\n\nconst extraProcessingTypes = new Set([\"merge\", \"remux\", \"mute\", \"audio\", \"gif\"]);\n\nexport default function({\n    r,\n    host,\n    audioFormat,\n    isAudioOnly,\n    isAudioMuted,\n    disableMetadata,\n    filenameStyle,\n    convertGif,\n    requestIP,\n    audioBitrate,\n    alwaysProxy,\n    localProcessing,\n}) {\n    let action,\n        responseType = \"tunnel\",\n        defaultParams = {\n            url: r.urls,\n            headers: r.headers,\n            service: host,\n            filename: r.filenameAttributes ?\n                    createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename,\n            fileMetadata: !disableMetadata ? r.fileMetadata : false,\n            requestIP,\n            originalRequest: r.originalRequest,\n            subtitles: r.subtitles,\n            cover: !disableMetadata ? r.cover : false,\n            cropCover: !disableMetadata ? r.cropCover : false,\n        },\n        params = {};\n\n    if (r.isPhoto) action = \"photo\";\n    else if (r.picker) action = \"picker\"\n    else if (r.isGif && convertGif) action = \"gif\";\n    else if (isAudioOnly) action = \"audio\";\n    else if (isAudioMuted) action = \"muteVideo\";\n    else if (r.isHLS) action = \"hls\";\n    else action = \"video\";\n\n    if (action === \"picker\" || action === \"audio\") {\n        if (!r.filenameAttributes) defaultParams.filename = r.audioFilename;\n        defaultParams.audioFormat = audioFormat;\n    }\n\n    if (action === \"muteVideo\" && isAudioMuted && !r.filenameAttributes) {\n        const [ name, ext ] = splitFilenameExtension(r.filename);\n        defaultParams.filename = `${name}_mute.${ext}`;\n    } else if (action === \"gif\") {\n        const [ name ] = splitFilenameExtension(r.filename);\n        defaultParams.filename = `${name}.gif`;\n    }\n\n    switch (action) {\n        default:\n            return createResponse(\"error\", {\n                code: \"error.api.fetch.empty\"\n            });\n\n        case \"photo\":\n            params = { type: \"proxy\" };\n            break;\n\n        case \"gif\":\n            params = { type: \"gif\" };\n            break;\n\n        case \"hls\":\n            params = {\n                type: Array.isArray(r.urls) ? \"merge\" : \"remux\",\n                isHLS: true,\n            }\n            break;\n\n        case \"muteVideo\":\n            let muteType = \"mute\";\n            if (Array.isArray(r.urls) && !r.isHLS) {\n                muteType = \"proxy\";\n            }\n            params = {\n                type: muteType,\n                url: Array.isArray(r.urls) ? r.urls[0] : r.urls,\n                isHLS: r.isHLS\n            }\n            if (host === \"reddit\" && r.typeId === \"redirect\") {\n                responseType = \"redirect\";\n            }\n            break;\n\n        case \"picker\":\n            responseType = \"picker\";\n            switch (host) {\n                case \"instagram\":\n                case \"twitter\":\n                case \"snapchat\":\n                case \"bsky\":\n                case \"xiaohongshu\":\n                    params = { picker: r.picker };\n                    break;\n\n                case \"tiktok\":\n                    let audioStreamType = \"audio\";\n                    if (r.bestAudio === \"mp3\" && audioFormat === \"best\") {\n                        audioFormat = \"mp3\";\n                        audioStreamType = \"proxy\"\n                    }\n                    params = {\n                        picker: r.picker,\n                        url: createStream({\n                            service: \"tiktok\",\n                            type: audioStreamType,\n                            url: r.urls,\n                            headers: r.headers,\n                            filename: `${r.audioFilename}.${audioFormat}`,\n                            isAudioOnly: true,\n                            audioFormat,\n                            audioBitrate\n                        })\n                    }\n                    break;\n            }\n            break;\n\n        case \"video\":\n            switch (host) {\n                case \"bilibili\":\n                    params = { type: \"merge\" };\n                    break;\n\n                case \"youtube\":\n                    params = { type: r.type };\n                    break;\n\n                case \"reddit\":\n                    responseType = r.typeId;\n                    params = { type: r.type };\n                    break;\n\n                case \"vimeo\":\n                    if (Array.isArray(r.urls)) {\n                        params = { type: \"merge\" };\n                    } else if (r.subtitles) {\n                        params = { type: \"remux\" };\n                    } else {\n                        responseType = \"redirect\";\n                    }\n                    break;\n\n                case \"twitter\":\n                    if (r.type === \"remux\") {\n                        params = { type: r.type };\n                    } else {\n                        responseType = \"redirect\";\n                    }\n                    break;\n\n                case \"loom\":\n                    if (r.subtitles) {\n                        params = { type: \"remux\" };\n                    } else {\n                        responseType = \"redirect\";\n                    }\n                    break;\n\n                case \"vk\":\n                case \"tiktok\":\n                    params = {\n                        type: r.subtitles ? \"remux\" : \"proxy\"\n                    };\n                    break;\n\n                case \"ok\":\n                case \"xiaohongshu\":\n                case \"newgrounds\":\n                    params = { type: \"proxy\" };\n                    break;\n\n                case \"facebook\":\n                case \"instagram\":\n                case \"tumblr\":\n                case \"pinterest\":\n                case \"streamable\":\n                case \"snapchat\":\n                case \"twitch\":\n                    responseType = \"redirect\";\n                    break;\n            }\n            break;\n\n        case \"audio\":\n            if (audioIgnore.has(host) || (host === \"reddit\" && r.typeId === \"redirect\")) {\n                return createResponse(\"error\", {\n                    code: \"error.api.service.audio_not_supported\"\n                })\n            }\n\n            let processType = \"audio\";\n            let copy = false;\n\n            if (audioFormat === \"best\") {\n                const serviceBestAudio = r.bestAudio;\n\n                if (serviceBestAudio) {\n                    audioFormat = serviceBestAudio;\n                    processType = \"proxy\";\n\n                    if (host === \"soundcloud\") {\n                        processType = \"audio\";\n                        copy = true;\n                    }\n                } else {\n                    audioFormat = \"m4a\";\n                    copy = true;\n                }\n            }\n\n            if (r.isHLS || host === \"vimeo\") {\n                copy = false;\n                processType = \"audio\";\n            }\n\n            params = {\n                type: processType,\n                url: Array.isArray(r.urls) ? r.urls[1] : r.urls,\n\n                audioBitrate,\n                audioCopy: copy,\n                audioFormat,\n\n                isHLS: r.isHLS,\n            }\n            break;\n    }\n\n    if (defaultParams.filename && (action === \"picker\" || action === \"audio\")) {\n        defaultParams.filename += `.${audioFormat}`;\n    }\n\n    // alwaysProxy is set to true in match.js if localProcessing is forced\n    if (alwaysProxy && responseType === \"redirect\") {\n        responseType = \"tunnel\";\n        params.type = \"proxy\";\n    }\n\n    // TODO: add support for HLS\n    // (very painful)\n    if (!params.isHLS && responseType !== \"picker\") {\n        const isPreferredWithExtra =\n            localProcessing === \"preferred\" && extraProcessingTypes.has(params.type);\n\n        if (localProcessing === \"forced\" || isPreferredWithExtra) {\n            responseType = \"local-processing\";\n        }\n    }\n\n    // extractors usually return ISO 639-1 language codes,\n    // but video players expect ISO 639-2, so we convert them here\n    const sublanguage = defaultParams.fileMetadata?.sublanguage;\n    if (sublanguage && sublanguage.length !== 3) {\n        const code = convertLanguageCode(sublanguage);\n        if (code) {\n            defaultParams.fileMetadata.sublanguage = code;\n        } else {\n            // if a language code couldn't be converted,\n            // then we don't want it at all\n            delete defaultParams.fileMetadata.sublanguage;\n        }\n    }\n\n    return createResponse(\n        responseType,\n        { ...defaultParams, ...params }\n    );\n}\n"
  },
  {
    "path": "api/src/processing/match.js",
    "content": "import { strict as assert } from \"node:assert\";\n\nimport { env } from \"../config.js\";\nimport { createResponse } from \"../processing/request.js\";\n\nimport { testers } from \"./service-patterns.js\";\nimport matchAction from \"./match-action.js\";\n\nimport { friendlyServiceName } from \"./service-alias.js\";\n\nimport bilibili from \"./services/bilibili.js\";\nimport reddit from \"./services/reddit.js\";\nimport twitter from \"./services/twitter.js\";\nimport youtube from \"./services/youtube.js\";\nimport vk from \"./services/vk.js\";\nimport ok from \"./services/ok.js\";\nimport tiktok from \"./services/tiktok.js\";\nimport tumblr from \"./services/tumblr.js\";\nimport vimeo from \"./services/vimeo.js\";\nimport soundcloud from \"./services/soundcloud.js\";\nimport instagram from \"./services/instagram.js\";\nimport pinterest from \"./services/pinterest.js\";\nimport streamable from \"./services/streamable.js\";\nimport twitch from \"./services/twitch.js\";\nimport rutube from \"./services/rutube.js\";\nimport dailymotion from \"./services/dailymotion.js\";\nimport snapchat from \"./services/snapchat.js\";\nimport loom from \"./services/loom.js\";\nimport facebook from \"./services/facebook.js\";\nimport bluesky from \"./services/bluesky.js\";\nimport xiaohongshu from \"./services/xiaohongshu.js\";\nimport newgrounds from \"./services/newgrounds.js\";\n\nlet freebind;\n\nexport default async function({ host, patternMatch, params, authType }) {\n    const { url } = params;\n    assert(url instanceof URL);\n    let dispatcher, requestIP;\n\n    if (env.freebindCIDR) {\n        if (!freebind) {\n            freebind = await import('freebind');\n        }\n\n        requestIP = freebind.ip.random(env.freebindCIDR);\n        dispatcher = freebind.dispatcherFromIP(requestIP, { strict: false });\n    }\n\n    try {\n        let r,\n            isAudioOnly = params.downloadMode === \"audio\",\n            isAudioMuted = params.downloadMode === \"mute\";\n\n        if (!testers[host]) {\n            return createResponse(\"error\", {\n                code: \"error.api.service.unsupported\"\n            });\n        }\n        if (!(testers[host](patternMatch))) {\n            return createResponse(\"error\", {\n                code: \"error.api.link.unsupported\",\n                context: {\n                    service: friendlyServiceName(host),\n                }\n            });\n        }\n\n        // youtubeHLS will be fully removed in the future\n        let youtubeHLS = params.youtubeHLS;\n        const hlsEnv = env.enableDeprecatedYoutubeHls;\n\n        if (hlsEnv === \"never\" || (hlsEnv === \"key\" && authType !== \"key\")) {\n            youtubeHLS = false;\n        }\n\n        const subtitleLang =\n            params.subtitleLang !== \"none\" ? params.subtitleLang : undefined;\n\n        switch (host) {\n            case \"twitter\":\n                r = await twitter({\n                    id: patternMatch.id,\n                    index: patternMatch.index - 1,\n                    toGif: !!params.convertGif,\n                    alwaysProxy: params.alwaysProxy,\n                    dispatcher,\n                    subtitleLang\n                });\n                break;\n\n            case \"vk\":\n                r = await vk({\n                    ownerId: patternMatch.ownerId,\n                    videoId: patternMatch.videoId,\n                    accessKey: patternMatch.accessKey,\n                    quality: params.videoQuality,\n                    subtitleLang,\n                });\n                break;\n\n            case \"ok\":\n                r = await ok({\n                    id: patternMatch.id,\n                    quality: params.videoQuality\n                });\n                break;\n\n            case \"bilibili\":\n                r = await bilibili(patternMatch);\n                break;\n\n            case \"youtube\":\n                let fetchInfo = {\n                    dispatcher,\n                    id: patternMatch.id.slice(0, 11),\n                    quality: params.videoQuality,\n                    codec: params.youtubeVideoCodec,\n                    container: params.youtubeVideoContainer,\n                    isAudioOnly,\n                    isAudioMuted,\n                    dubLang: params.youtubeDubLang,\n                    youtubeHLS,\n                    subtitleLang,\n                }\n\n                if (url.hostname === \"music.youtube.com\" || isAudioOnly) {\n                    fetchInfo.quality = \"1080\";\n                    fetchInfo.codec = \"vp9\";\n                    fetchInfo.isAudioOnly = true;\n                    fetchInfo.isAudioMuted = false;\n\n                    if (env.ytAllowBetterAudio && params.youtubeBetterAudio) {\n                        fetchInfo.quality = \"max\";\n                    }\n                }\n\n                r = await youtube(fetchInfo);\n                break;\n\n            case \"reddit\":\n                r = await reddit({\n                    ...patternMatch,\n                    dispatcher,\n                });\n                break;\n\n            case \"tiktok\":\n                r = await tiktok({\n                    postId: patternMatch.postId,\n                    shortLink: patternMatch.shortLink,\n                    fullAudio: params.tiktokFullAudio,\n                    isAudioOnly,\n                    h265: params.allowH265,\n                    alwaysProxy: params.alwaysProxy,\n                    subtitleLang,\n                });\n                break;\n\n            case \"tumblr\":\n                r = await tumblr({\n                    id: patternMatch.id,\n                    user: patternMatch.user,\n                    url\n                });\n                break;\n\n            case \"vimeo\":\n                r = await vimeo({\n                    id: patternMatch.id.slice(0, 11),\n                    password: patternMatch.password,\n                    quality: params.videoQuality,\n                    isAudioOnly,\n                    subtitleLang,\n                });\n                break;\n\n            case \"soundcloud\":\n                isAudioOnly = true;\n                isAudioMuted = false;\n                r = await soundcloud({\n                    ...patternMatch,\n                    format: params.audioFormat,\n                });\n                break;\n\n            case \"instagram\":\n                r = await instagram({\n                    ...patternMatch,\n                    quality: params.videoQuality,\n                    alwaysProxy: params.alwaysProxy,\n                    dispatcher\n                })\n                break;\n\n            case \"pinterest\":\n                r = await pinterest({\n                    id: patternMatch.id,\n                    shortLink: patternMatch.shortLink || false\n                });\n                break;\n\n            case \"streamable\":\n                r = await streamable({\n                    id: patternMatch.id,\n                    quality: params.videoQuality,\n                    isAudioOnly,\n                });\n                break;\n\n            case \"twitch\":\n                r = await twitch({\n                    clipId: patternMatch.clip || false,\n                    quality: params.videoQuality,\n                    isAudioOnly,\n                });\n                break;\n\n            case \"rutube\":\n                r = await rutube({\n                    id: patternMatch.id,\n                    yappyId: patternMatch.yappyId,\n                    key: patternMatch.key,\n                    quality: params.videoQuality,\n                    isAudioOnly,\n                    subtitleLang,\n                });\n                break;\n\n            case \"dailymotion\":\n                r = await dailymotion(patternMatch);\n                break;\n\n            case \"snapchat\":\n                r = await snapchat({\n                    ...patternMatch,\n                    alwaysProxy: params.alwaysProxy,\n                });\n                break;\n\n            case \"loom\":\n                r = await loom({\n                    id: patternMatch.id,\n                    subtitleLang,\n                });\n                break;\n\n            case \"facebook\":\n                r = await facebook({\n                    ...patternMatch,\n                    dispatcher\n                });\n                break;\n\n            case \"bsky\":\n                r = await bluesky({\n                    ...patternMatch,\n                    alwaysProxy: params.alwaysProxy,\n                    dispatcher\n                });\n                break;\n\n            case \"xiaohongshu\":\n                r = await xiaohongshu({\n                    ...patternMatch,\n                    h265: params.allowH265,\n                    isAudioOnly,\n                    dispatcher,\n                });\n                break;\n\n            case \"newgrounds\":\n                r = await newgrounds({\n                    ...patternMatch,\n                    quality: params.videoQuality,\n                });\n                break;\n\n            default:\n                return createResponse(\"error\", {\n                    code: \"error.api.service.unsupported\"\n                });\n        }\n\n        if (r.isAudioOnly) {\n            isAudioOnly = true;\n            isAudioMuted = false;\n        }\n\n        if (r.error && r.critical) {\n            return createResponse(\"critical\", {\n                code: `error.api.${r.error}`,\n            })\n        }\n\n        if (r.error) {\n            let context;\n            switch(r.error) {\n                case \"content.too_long\":\n                    context = {\n                        limit: parseFloat((env.durationLimit / 60).toFixed(2)),\n                    }\n                    break;\n\n                case \"fetch.fail\":\n                case \"fetch.rate\":\n                case \"fetch.critical\":\n                case \"link.unsupported\":\n                case \"content.video.unavailable\":\n                    context = {\n                        service: friendlyServiceName(host),\n                    }\n                    break;\n            }\n\n            return createResponse(\"error\", {\n                code: `error.api.${r.error}`,\n                context,\n            })\n        }\n\n        let localProcessing = params.localProcessing;\n        const lpEnv = env.forceLocalProcessing;\n        const shouldForceLocal = lpEnv === \"always\" || (lpEnv === \"session\" && authType === \"session\");\n        const localDisabled = (!localProcessing || localProcessing === \"disabled\");\n\n        if (shouldForceLocal && localDisabled) {\n            localProcessing = \"preferred\";\n        }\n\n        return matchAction({\n            r,\n            host,\n            audioFormat: params.audioFormat,\n            isAudioOnly,\n            isAudioMuted,\n            disableMetadata: params.disableMetadata,\n            filenameStyle: params.filenameStyle,\n            convertGif: params.convertGif,\n            requestIP,\n            audioBitrate: params.audioBitrate,\n            alwaysProxy: params.alwaysProxy || localProcessing === \"forced\",\n            localProcessing,\n        })\n    } catch {\n        return createResponse(\"error\", {\n            code: \"error.api.fetch.critical\",\n            context: {\n                service: friendlyServiceName(host),\n            }\n        })\n    }\n}\n"
  },
  {
    "path": "api/src/processing/request.js",
    "content": "import mime from \"mime\";\nimport ipaddr from \"ipaddr.js\";\n\nimport { apiSchema } from \"./schema.js\";\nimport { createProxyTunnels, createStream } from \"../stream/manage.js\";\n\nexport function createResponse(responseType, responseData) {\n    const internalError = (code) => {\n        return {\n            status: 500,\n            body: {\n                status: \"error\",\n                error: {\n                    code: code || \"error.api.fetch.critical.core\",\n                },\n                critical: true\n            }\n        }\n    }\n\n    try {\n        let status = 200,\n            response = {};\n\n        if (responseType === \"error\") {\n            status = 400;\n        }\n\n        switch (responseType) {\n            case \"error\":\n                response = {\n                    error: {\n                        code: responseData?.code,\n                        context: responseData?.context,\n                    }\n                }\n                break;\n\n            case \"redirect\":\n                response = {\n                    url: responseData?.url,\n                    filename: responseData?.filename\n                }\n                break;\n\n            case \"tunnel\":\n                response = {\n                    url: createStream(responseData),\n                    filename: responseData?.filename\n                }\n                break;\n\n            case \"local-processing\":\n                response = {\n                    type: responseData?.type,\n                    service: responseData?.service,\n                    tunnel: createProxyTunnels(responseData),\n\n                    output: {\n                        type: mime.getType(responseData?.filename) || undefined,\n                        filename: responseData?.filename,\n                        metadata: responseData?.fileMetadata || undefined,\n                        subtitles: !!responseData?.subtitles || undefined,\n                    },\n\n                    audio: {\n                        copy: responseData?.audioCopy,\n                        format: responseData?.audioFormat,\n                        bitrate: responseData?.audioBitrate,\n                        cover: !!responseData?.cover || undefined,\n                        cropCover: !!responseData?.cropCover || undefined,\n                    },\n\n                    isHLS: responseData?.isHLS,\n                }\n\n                if (!response.audio.format) {\n                    if (response.type === \"audio\") {\n                        // audio response without a format is invalid\n                        return internalError();\n                    }\n                    delete response.audio;\n                }\n\n                if (!response.output.type || !response.output.filename) {\n                    // response without a type or filename is invalid\n                    return internalError();\n                }\n                break;\n\n            case \"picker\":\n                response = {\n                    picker: responseData?.picker,\n                    audio: responseData?.url,\n                    audioFilename: responseData?.filename\n                }\n                break;\n\n            case \"critical\":\n                return internalError(responseData?.code);\n\n            default:\n                throw \"unreachable\"\n        }\n\n        return {\n            status,\n            body: {\n                status: responseType,\n                ...response\n            }\n        }\n    } catch {\n        return internalError();\n    }\n}\n\nexport function normalizeRequest(request) {\n    // TODO: remove after backwards compatibility period\n    if (\"localProcessing\" in request && typeof request.localProcessing === \"boolean\") {\n        request.localProcessing = request.localProcessing ? \"preferred\" : \"disabled\";\n    }\n\n    return apiSchema.safeParseAsync(request).catch(() => (\n        { success: false }\n    ));\n}\n\nexport function getIP(req, prefix = 56) {\n    const strippedIP = req.ip.replace(/^::ffff:/, '');\n    const ip = ipaddr.parse(strippedIP);\n    if (ip.kind() === 'ipv4') {\n        return strippedIP;\n    }\n\n    const v6Bytes = ip.toByteArray();\n          v6Bytes.fill(0, prefix / 8);\n\n    return ipaddr.fromByteArray(v6Bytes).toString();\n}\n"
  },
  {
    "path": "api/src/processing/schema.js",
    "content": "import { z } from \"zod\";\nimport { normalizeURL } from \"./url.js\";\n\nexport const apiSchema = z.object({\n    url: z.string()\n          .min(1)\n          .transform(url => normalizeURL(url)),\n\n    audioBitrate: z.enum(\n        [\"320\", \"256\", \"128\", \"96\", \"64\", \"8\"]\n    ).default(\"128\"),\n\n    audioFormat: z.enum(\n        [\"best\", \"mp3\", \"ogg\", \"wav\", \"opus\"]\n    ).default(\"mp3\"),\n\n    downloadMode: z.enum(\n        [\"auto\", \"audio\", \"mute\"]\n    ).default(\"auto\"),\n\n    filenameStyle: z.enum(\n        [\"classic\", \"pretty\", \"basic\", \"nerdy\"]\n    ).default(\"basic\"),\n\n    youtubeVideoCodec: z.enum(\n        [\"h264\", \"av1\", \"vp9\"]\n    ).default(\"h264\"),\n\n    youtubeVideoContainer: z.enum(\n        [\"auto\", \"mp4\", \"webm\", \"mkv\"]\n    ).default(\"auto\"),\n\n    videoQuality: z.enum(\n        [\"max\", \"4320\", \"2160\", \"1440\", \"1080\", \"720\", \"480\", \"360\", \"240\", \"144\"]\n    ).default(\"1080\"),\n\n    localProcessing: z.enum(\n        [\"disabled\", \"preferred\", \"forced\"]\n    ).default(\"disabled\"),\n\n    youtubeDubLang: z.string()\n                     .min(2)\n                     .max(8)\n                     .regex(/^[0-9a-zA-Z\\-]+$/)\n                     .optional(),\n\n    subtitleLang: z.string()\n                     .min(2)\n                     .max(8)\n                     .regex(/^[0-9a-zA-Z\\-]+$/)\n                     .optional(),\n\n    disableMetadata: z.boolean().default(false),\n\n    allowH265: z.boolean().default(false),\n    convertGif: z.boolean().default(true),\n    tiktokFullAudio: z.boolean().default(false),\n\n    alwaysProxy: z.boolean().default(false),\n\n    youtubeHLS: z.boolean().default(false),\n    youtubeBetterAudio: z.boolean().default(false),\n})\n.strict();\n"
  },
  {
    "path": "api/src/processing/service-alias.js",
    "content": "const friendlyNames = {\n    bsky: \"bluesky\",\n    twitch: \"twitch clips\"\n}\n\nexport const friendlyServiceName = (service) => {\n    if (service in friendlyNames) {\n        return friendlyNames[service];\n    }\n    return service;\n}\n"
  },
  {
    "path": "api/src/processing/service-config.js",
    "content": "import UrlPattern from \"url-pattern\";\n\nexport const audioIgnore = new Set([\"vk\", \"ok\", \"loom\"]);\nexport const hlsExceptions = new Set([\"dailymotion\", \"vimeo\", \"rutube\", \"bsky\", \"youtube\"]);\n\nexport const services = {\n    bilibili: {\n        patterns: [\n            \"video/:comId\",\n            \"video/:comId?p=:partId\",\n            \"_shortLink/:comShortLink\",\n            \"_tv/:lang/video/:tvId\",\n            \"_tv/video/:tvId\"\n        ],\n        subdomains: [\"m\"],\n    },\n    bsky: {\n        patterns: [\n            \"profile/:user/post/:post\"\n        ],\n        tld: \"app\",\n    },\n    dailymotion: {\n        patterns: [\"video/:id\"],\n    },\n    facebook: {\n        patterns: [\n            \"_shortLink/:shortLink\",\n            \":username/videos/:caption/:id\",\n            \":username/videos/:id\",\n            \"reel/:id\",\n            \"share/:shareType/:id\"\n        ],\n        subdomains: [\"web\", \"m\"],\n        altDomains: [\"fb.watch\"],\n    },\n    instagram: {\n        patterns: [\n            \"p/:postId\",\n            \"tv/:postId\",\n            \"reel/:postId\",\n            \"reels/:postId\",\n            \"stories/:username/:storyId\",\n\n            /*\n                share & username links use the same url pattern,\n                so we test the share pattern first, cuz id type is different.\n                however, if someone has the \"share\" username and the user\n                somehow gets a link of this ancient style, it's joever.\n            */\n\n            \"share/:shareId\",\n            \"share/p/:shareId\",\n            \"share/reel/:shareId\",\n\n            \":username/p/:postId\",\n            \":username/reel/:postId\",\n        ],\n        altDomains: [\"ddinstagram.com\"],\n    },\n    loom: {\n        patterns: [\"share/:id\", \"embed/:id\"],\n    },\n    ok: {\n        patterns: [\n            \"video/:id\",\n            \"videoembed/:id\"\n        ],\n        tld: \"ru\",\n    },\n    pinterest: {\n        patterns: [\n            \"pin/:id\",\n            \"pin/:id/:garbage\",\n            \"url_shortener/:shortLink\"\n        ],\n    },\n    newgrounds: {\n        patterns: [\n            \"portal/view/:id\",\n            \"audio/listen/:audioId\",\n        ]\n    },\n    reddit: {\n        patterns: [\n            \"comments/:id\",\n\n            \"r/:sub/comments/:id\",\n            \"r/:sub/comments/:id/:title\",\n            \"r/:sub/comments/:id/comment/:commentId\",\n\n            \"user/:user/comments/:id\",\n            \"user/:user/comments/:id/:title\",\n            \"user/:user/comments/:id/comment/:commentId\",\n\n            \"r/u_:user/comments/:id\",\n            \"r/u_:user/comments/:id/:title\",\n            \"r/u_:user/comments/:id/comment/:commentId\",\n\n            \"r/:sub/s/:shareId\",\n\n            \"video/:shortId\",\n        ],\n        subdomains: \"*\",\n    },\n    rutube: {\n        patterns: [\n            \"video/:id\",\n            \"play/embed/:id\",\n            \"shorts/:id\",\n            \"yappy/:yappyId\",\n            \"video/private/:id?p=:key\",\n            \"video/private/:id\"\n        ],\n        tld: \"ru\",\n    },\n    snapchat: {\n        patterns: [\n            \":shortLink\",\n            \"spotlight/:spotlightId\",\n            \"add/:username/:storyId\",\n            \"u/:username/:storyId\",\n            \"add/:username\",\n            \"u/:username\",\n            \"t/:shortLink\",\n            \"o/:spotlightId\",\n        ],\n        subdomains: [\"t\", \"story\"],\n    },\n    soundcloud: {\n        patterns: [\n            \":author/:song/s-:accessKey\",\n            \":author/:song\",\n            \":shortLink\"\n        ],\n        subdomains: [\"on\", \"m\"],\n    },\n    streamable: {\n        patterns: [\n            \":id\",\n            \"o/:id\",\n            \"e/:id\",\n            \"s/:id\"\n        ],\n    },\n    tiktok: {\n        patterns: [\n            \":user/video/:postId\",\n            \"i18n/share/video/:postId\",\n            \":shortLink\",\n            \"t/:shortLink\",\n            \":user/photo/:postId\",\n            \"v/:postId.html\"\n        ],\n        subdomains: [\"vt\", \"vm\", \"m\", \"t\", \"pro\"],\n    },\n    tumblr: {\n        patterns: [\n            \"post/:id\",\n            \"blog/view/:user/:id\",\n            \":user/:id\",\n            \":user/:id/:trackingId\"\n        ],\n        subdomains: \"*\",\n    },\n    twitch: {\n        patterns: [\":channel/clip/:clip\"],\n        tld: \"tv\",\n        subdomains: [\"clips\", \"www\", \"m\"],\n    },\n    twitter: {\n        patterns: [\n            \":user/status/:id\",\n            \":user/status/:id/video/:index\",\n            \":user/status/:id/photo/:index\",\n            \":user/status/:id/mediaviewer\",\n            \":user/status/:id/mediaViewer\",\n            \"i/bookmarks?post_id=:id\"\n        ],\n        subdomains: [\"mobile\"],\n        altDomains: [\"x.com\", \"vxtwitter.com\", \"fixvx.com\"],\n    },\n    vimeo: {\n        patterns: [\n            \":id\",\n            \"video/:id\",\n            \":id/:password\",\n            \"/channels/:user/:id\",\n            \"groups/:groupId/videos/:id\"\n        ],\n        subdomains: [\"player\"],\n    },\n    vk: {\n        patterns: [\n            \"video:ownerId_:videoId\",\n            \"clip:ownerId_:videoId\",\n            \"video:ownerId_:videoId_:accessKey\",\n            \"clip:ownerId_:videoId_:accessKey\",\n\n            // links with a duplicate author id and/or zipper query param\n            \"clips:duplicateId\",\n            \"videos:duplicateId\",\n            \"search/video\"\n        ],\n        subdomains: [\"m\"],\n        altDomains: [\"vkvideo.ru\", \"vk.ru\"],\n    },\n    xiaohongshu: {\n        patterns: [\n            \"explore/:id?xsec_token=:token\",\n            \"discovery/item/:id?xsec_token=:token\",\n            \":shareType/:shareId\",\n        ],\n        altDomains: [\"xhslink.com\"],\n    },\n    youtube: {\n        patterns: [\n            \"watch?v=:id\",\n            \"embed/:id\",\n            \"watch/:id\",\n            \"v/:id\"\n        ],\n        subdomains: [\"music\", \"m\"],\n    }\n}\n\nObject.values(services).forEach(service => {\n    service.patterns = service.patterns.map(\n        pattern => new UrlPattern(pattern, {\n            segmentValueCharset: UrlPattern.defaultOptions.segmentValueCharset + '@\\\\.:'\n        })\n    )\n})\n"
  },
  {
    "path": "api/src/processing/service-patterns.js",
    "content": "export const testers = {\n    \"bilibili\": pattern =>\n        (pattern.comId?.length <= 12 && pattern.partId?.length <= 3) ||\n        (pattern.comId?.length <= 12 && !pattern.partId) ||\n        pattern.comShortLink?.length <= 16 ||\n        pattern.tvId?.length <= 24,\n\n    \"bsky\": pattern =>\n        pattern.user?.length <= 128 && pattern.post?.length <= 128,\n\n    \"dailymotion\": pattern => pattern.id?.length <= 32,\n\n    \"facebook\": pattern =>\n        pattern.shortLink?.length <= 11 ||\n        pattern.username?.length <= 30 ||\n        pattern.caption?.length <= 255 ||\n        pattern.id?.length <= 20 && !pattern.shareType ||\n        pattern.id?.length <= 20 && pattern.shareType?.length === 1,\n\n    \"instagram\": pattern =>\n        pattern.postId?.length <= 48 ||\n        pattern.shareId?.length <= 16 ||\n        (pattern.username?.length <= 30 && pattern.storyId?.length <= 24),\n\n    \"loom\": pattern =>\n        pattern.id?.length <= 32,\n\n    \"newgrounds\": pattern =>\n        pattern.id?.length <= 12 ||\n        pattern.audioId?.length <= 12,\n\n    \"ok\": pattern =>\n        pattern.id?.length <= 16,\n\n    \"pinterest\": pattern =>\n        pattern.id?.length <= 128 ||\n        pattern.shortLink?.length <= 32,\n\n    \"reddit\": pattern =>\n        pattern.id?.length <= 16 && !pattern.sub && !pattern.user ||\n        (pattern.sub?.length <= 22 && pattern.id?.length <= 16) ||\n        (pattern.user?.length <= 22 && pattern.id?.length <= 16) ||\n        (pattern.sub?.length <= 22 && pattern.shareId?.length <= 16) ||\n        (pattern.shortId?.length <= 16),\n\n    \"rutube\": pattern =>\n        (pattern.id?.length === 32 && pattern.key?.length <= 32) ||\n        pattern.id?.length === 32 ||\n        pattern.yappyId?.length === 32,\n\n    \"snapchat\": pattern =>\n        (pattern.username?.length <= 32 && (!pattern.storyId || pattern.storyId?.length <= 255)) ||\n        pattern.spotlightId?.length <= 255 ||\n        pattern.shortLink?.length <= 16,\n\n    \"soundcloud\": pattern =>\n        (pattern.author?.length <= 255 && pattern.song?.length <= 255) ||\n        pattern.shortLink?.length <= 32,\n\n    \"streamable\": pattern =>\n        pattern.id?.length <= 6,\n\n    \"tiktok\": pattern =>\n        pattern.postId?.length <= 21 ||\n        pattern.shortLink?.length <= 21,\n\n    \"tumblr\": pattern =>\n        pattern.id?.length < 21 ||\n        (pattern.id?.length < 21 && pattern.user?.length <= 32),\n\n    \"twitch\": pattern =>\n        pattern.channel && pattern.clip?.length <= 100,\n\n    \"twitter\": pattern =>\n        pattern.id?.length < 20,\n\n    \"vimeo\": pattern =>\n        pattern.id?.length <= 11 && (!pattern.password || pattern.password.length < 16),\n\n    \"vk\": pattern =>\n        (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10) ||\n        (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10 && pattern.videoId?.accessKey <= 18),\n\n    \"xiaohongshu\": pattern =>\n        pattern.id?.length <= 24 && pattern.token?.length <= 64 ||\n        pattern.shareId?.length <= 24 && pattern.shareType?.length === 1,\n\n    \"youtube\": pattern =>\n        pattern.id?.length <= 11,\n}\n"
  },
  {
    "path": "api/src/processing/services/bilibili.js",
    "content": "import { genericUserAgent, env } from \"../../config.js\";\nimport { resolveRedirectingURL } from \"../url.js\";\n\n// TO-DO: higher quality downloads (currently requires an account)\n\nfunction getBest(content) {\n    return content?.filter(v => v.baseUrl || v.url)\n                .map(v => (v.baseUrl = v.baseUrl || v.url, v))\n                .reduce((a, b) => a?.bandwidth > b?.bandwidth ? a : b);\n}\n\nfunction extractBestQuality(dashData) {\n    const bestVideo = getBest(dashData.video),\n          bestAudio = getBest(dashData.audio);\n\n    if (!bestVideo || !bestAudio) return [];\n    return [ bestVideo, bestAudio ];\n}\n\nasync function com_download(id, partId) {\n    const url = new URL(`https://bilibili.com/video/${id}`);\n\n    if (partId) {\n        url.searchParams.set('p', partId);\n    }\n\n    const html = await fetch(url, {\n        headers: {\n            \"user-agent\": genericUserAgent\n        }\n    })\n    .then(r => r.text())\n    .catch(() => {});\n\n    if (!html) {\n        return { error: \"fetch.fail\" }\n    }\n\n    if (!(html.includes('<script>window.__playinfo__=') && html.includes('\"video_codecid\"'))) {\n        return { error: \"fetch.empty\" };\n    }\n\n    const streamData = JSON.parse(\n        html.split('<script>window.__playinfo__=')[1].split('</script>')[0]\n    );\n\n    if (streamData.data.timelength > env.durationLimit * 1000) {\n        return { error: \"content.too_long\" };\n    }\n\n    const [ video, audio ] = extractBestQuality(streamData.data.dash);\n    if (!video || !audio) {\n        return { error: \"fetch.empty\" };\n    }\n\n    let filenameBase = `bilibili_${id}`;\n    if (partId) {\n        filenameBase += `_${partId}`;\n    }\n\n    return {\n        urls: [video.baseUrl, audio.baseUrl],\n        audioFilename: `${filenameBase}_audio`,\n        filename: `${filenameBase}_${video.width}x${video.height}.mp4`,\n    };\n}\n\nasync function tv_download(id) {\n    const url = new URL(\n        'https://api.bilibili.tv/intl/gateway/web/playurl'\n        + '?s_locale=en_US&platform=web&qn=64&type=0&device=wap'\n        + '&tf=0&spm_id=bstar-web.ugc-video-detail.0.0&from_spm_id='\n    );\n\n    url.searchParams.set('aid', id);\n\n    const { data } = await fetch(url).then(a => a.json());\n    if (!data?.playurl?.video) {\n        return { error: \"fetch.empty\" };\n    }\n\n    const [ video, audio ] = extractBestQuality({\n        video: data.playurl.video.map(s => s.video_resource)\n                                 .filter(s => s.codecs.includes('avc1')),\n        audio: data.playurl.audio_resource\n    });\n\n    if (!video || !audio) {\n        return { error: \"fetch.empty\" };\n    }\n\n    if (video.duration > env.durationLimit * 1000) {\n        return { error: \"content.too_long\" };\n    }\n\n    return {\n        urls: [video.url, audio.url],\n        audioFilename: `bilibili_tv_${id}_audio`,\n        filename: `bilibili_tv_${id}.mp4`\n    };\n}\n\nexport default async function({ comId, tvId, comShortLink, partId }) {\n    if (comShortLink) {\n        const patternMatch = await resolveRedirectingURL(`https://b23.tv/${comShortLink}`);\n        comId = patternMatch?.comId;\n    }\n\n    if (comId) {\n        return com_download(comId, partId);\n    } else if (tvId) {\n        return tv_download(tvId);\n    }\n\n    return { error: \"fetch.fail\" };\n}\n"
  },
  {
    "path": "api/src/processing/services/bluesky.js",
    "content": "import HLS from \"hls-parser\";\nimport { cobaltUserAgent } from \"../../config.js\";\nimport { createStream } from \"../../stream/manage.js\";\n\nconst extractVideo = async ({ media, filename, dispatcher }) => {\n    let urlMasterHLS = media?.playlist;\n\n    if (!urlMasterHLS || !urlMasterHLS.startsWith(\"https://video.bsky.app/\")) {\n        return { error: \"fetch.empty\" };\n    }\n\n    urlMasterHLS = urlMasterHLS.replace(\n        \"video.bsky.app/watch/\",\n        \"video.cdn.bsky.app/hls/\"\n    );\n\n    const masterHLS = await fetch(urlMasterHLS, { dispatcher })\n        .then(r => {\n            if (r.status !== 200) return;\n            return r.text();\n        })\n        .catch(() => {});\n\n    if (!masterHLS) return { error: \"fetch.empty\" };\n\n    const video = HLS.parse(masterHLS)\n            ?.variants\n            ?.reduce((a, b) => a?.bandwidth > b?.bandwidth ? a : b);\n\n    const videoURL = new URL(video.uri, urlMasterHLS).toString();\n\n    return {\n        urls: videoURL,\n        filename: `${filename}.mp4`,\n        audioFilename: `${filename}_audio`,\n        isHLS: true,\n    }\n}\n\nconst extractImages = ({ getPost, filename, alwaysProxy }) => {\n    const images = getPost?.thread?.post?.embed?.images;\n\n    if (!images || images.length === 0) {\n        return { error: \"fetch.empty\" };\n    }\n\n    if (images.length === 1) return {\n        urls: images[0].fullsize,\n        isPhoto: true,\n        filename: `${filename}.jpg`,\n    }\n\n    const picker = images.map((image, i) => {\n        let url = image.fullsize;\n        let proxiedImage = createStream({\n            service: \"bluesky\",\n            type: \"proxy\",\n            url,\n            filename: `${filename}_${i + 1}.jpg`,\n        });\n\n        if (alwaysProxy) url = proxiedImage;\n\n        return {\n            type: \"photo\",\n            url,\n            thumb: proxiedImage,\n        }\n    });\n\n    return { picker };\n}\n\nconst extractGif = ({ url, filename }) => {\n    const gifUrl = new URL(url);\n\n    if (!gifUrl || gifUrl.hostname !== \"media.tenor.com\") {\n        return { error: \"fetch.empty\" };\n    }\n\n    // remove downscaling params from gif url\n    // such as \"?hh=498&ww=498\"\n    gifUrl.search = \"\";\n\n    return {\n        urls: gifUrl,\n        isPhoto: true,\n        filename: `${filename}.gif`,\n    }\n}\n\nexport default async function ({ user, post, alwaysProxy, dispatcher }) {\n    const apiEndpoint = new URL(\"https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?depth=0&parentHeight=0\");\n    apiEndpoint.searchParams.set(\n        \"uri\",\n        `at://${user}/app.bsky.feed.post/${post}`\n    );\n\n    const getPost = await fetch(apiEndpoint, {\n        headers: {\n            \"user-agent\": cobaltUserAgent,\n        },\n        dispatcher\n    }).then(r => r.json()).catch(() => {});\n\n    if (!getPost) return { error: \"fetch.empty\" };\n\n    if (getPost.error) {\n        switch (getPost.error) {\n            case \"NotFound\":\n            case \"InternalServerError\":\n                return { error: \"content.post.unavailable\" };\n            case \"InvalidRequest\":\n                return { error: \"link.unsupported\" };\n            default:\n                return { error: \"content.post.unavailable\" };\n        }\n    }\n\n    const embedType = getPost?.thread?.post?.embed?.$type;\n    const filename = `bluesky_${user}_${post}`;\n\n    switch (embedType) {\n        case \"app.bsky.embed.video#view\":\n            return extractVideo({\n                media: getPost.thread?.post?.embed,\n                filename,\n            });\n\n        case \"app.bsky.embed.images#view\":\n            return extractImages({\n                getPost,\n                filename,\n                alwaysProxy\n            });\n\n        case \"app.bsky.embed.external#view\":\n            return extractGif({\n                url: getPost?.thread?.post?.embed?.external?.uri,\n                filename,\n            });\n\n        case \"app.bsky.embed.recordWithMedia#view\":\n            if (getPost?.thread?.post?.embed?.media?.$type === \"app.bsky.embed.external#view\") {\n                return extractGif({\n                    url: getPost?.thread?.post?.embed?.media?.external?.uri,\n                    filename,\n                });\n            }\n            return extractVideo({\n                media: getPost.thread?.post?.embed?.media,\n                filename,\n            });\n    }\n\n    return { error: \"fetch.empty\" };\n}\n"
  },
  {
    "path": "api/src/processing/services/dailymotion.js",
    "content": "import HLSParser from \"hls-parser\";\nimport { env } from \"../../config.js\";\n\nlet _token;\n\nfunction getExp(token) {\n    return JSON.parse(\n        Buffer.from(token.split('.')[1], 'base64')\n    ).exp * 1000;\n}\n\nconst getToken = async () => {\n    if (_token && getExp(_token) > new Date().getTime()) {\n        return _token;\n    }\n\n    const req = await fetch('https://graphql.api.dailymotion.com/oauth/token', {\n        method: 'POST',\n        headers: {\n            'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',\n            'User-Agent': 'dailymotion/240213162706 CFNetwork/1492.0.1 Darwin/23.3.0',\n            'Authorization': 'Basic MGQyZDgyNjQwOWFmOWU3MmRiNWQ6ODcxNmJmYTVjYmEwMmUwMGJkYTVmYTg1NTliNDIwMzQ3NzIyYWMzYQ=='\n        },\n        body: 'traffic_segment=&grant_type=client_credentials'\n    }).then(r => r.json()).catch(() => {});\n\n    if (req.access_token) {\n        return _token = req.access_token;\n    }\n}\n\nexport default async function({ id }) {\n    const token = await getToken();\n    if (!token) return { error: \"fetch.fail\" };\n\n    const req = await fetch('https://graphql.api.dailymotion.com/',\n        {\n            method: 'POST',\n            headers: {\n                'User-Agent': 'dailymotion/240213162706 CFNetwork/1492.0.1 Darwin/23.3.0',\n                Authorization: `Bearer ${token}`,\n                'Content-Type': 'application/json',\n                'X-DM-AppInfo-Version': '7.16.0_240213162706',\n                'X-DM-AppInfo-Type': 'iosapp',\n                'X-DM-AppInfo-Id': 'com.dailymotion.dailymotion'\n            },\n            body: JSON.stringify({\n                operationName: \"Media\",\n                query: `\n                    query Media($xid: String!, $password: String) {\n                        media(xid: $xid, password: $password) {\n                        __typename\n                            ... on Video {\n                                xid\n                                hlsURL\n                                duration\n                                title\n                                channel {\n                                    displayName\n                                }\n                            }\n                        }\n                    }\n                `,\n                variables: { xid: id }\n            })\n        }\n    ).then(r => r.status === 200 && r.json()).catch(() => {});\n\n    const media = req?.data?.media;\n\n    if (media?.__typename !== 'Video' || !media.hlsURL) {\n        return { error: \"fetch.empty\" }\n    }\n\n    if (media.duration > env.durationLimit) {\n        return { error: \"content.too_long\" };\n    }\n\n    const manifest = await fetch(media.hlsURL).then(r => r.text()).catch(() => {});\n    if (!manifest) return { error: \"fetch.fail\" };\n\n    const bestQuality = HLSParser.parse(manifest).variants\n                        .filter(v => v.codecs.includes('avc1'))\n                        .reduce((a, b) => a.bandwidth > b.bandwidth ? a : b);\n    if (!bestQuality) return { error: \"fetch.empty\" }\n\n    const fileMetadata = {\n        title: media.title,\n        artist: media.channel.displayName\n    }\n\n    return {\n        urls: bestQuality.uri,\n        isHLS: true,\n        filenameAttributes: {\n            service: 'dailymotion',\n            id: media.xid,\n            title: fileMetadata.title,\n            author: fileMetadata.artist,\n            resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,\n            qualityLabel: `${bestQuality.resolution.height}p`,\n            extension: 'mp4'\n        },\n        fileMetadata\n    }\n}"
  },
  {
    "path": "api/src/processing/services/facebook.js",
    "content": "import { genericUserAgent } from \"../../config.js\";\n\nconst headers = {\n    'User-Agent': genericUserAgent,\n    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',\n    'Accept-Language': 'en-US,en;q=0.5',\n    'Sec-Fetch-Mode': 'navigate',\n    'Sec-Fetch-Site': 'none',\n}\n\nconst resolveUrl = (url, dispatcher) => {\n    return fetch(url, { headers, dispatcher })\n        .then(r => {\n            if (r.headers.get('location')) {\n                return decodeURIComponent(r.headers.get('location'));\n            }\n            if (r.headers.get('link')) {\n                const linkMatch = r.headers.get('link').match(/<(.*?)\\/>/);\n                return decodeURIComponent(linkMatch[1]);\n            }\n            return false;\n        })\n        .catch(() => false);\n}\n\nexport default async function({ id, shareType, shortLink, dispatcher }) {\n    let url = `https://web.facebook.com/i/videos/${id}`;\n\n    if (shareType) url = `https://web.facebook.com/share/${shareType}/${id}`;\n    if (shortLink) url = await resolveUrl(`https://fb.watch/${shortLink}`, dispatcher);\n\n    const html = await fetch(url, { headers, dispatcher })\n        .then(r => r.text())\n        .catch(() => false);\n\n    if (!html && shortLink) return { error: \"fetch.short_link\" }\n    if (!html) return { error: \"fetch.fail\" };\n\n    const urls = [];\n    const hd = html.match('\"browser_native_hd_url\":(\".*?\")');\n    const sd = html.match('\"browser_native_sd_url\":(\".*?\")');\n\n    if (hd?.[1]) urls.push(JSON.parse(hd[1]));\n    if (sd?.[1]) urls.push(JSON.parse(sd[1]));\n\n    if (!urls.length) {\n        return { error: \"fetch.empty\" };\n    }\n\n    const baseFilename = `facebook_${id || shortLink}`;\n\n    return {\n        urls: urls[0],\n        filename: `${baseFilename}.mp4`,\n        audioFilename: `${baseFilename}_audio`,\n    };\n}\n"
  },
  {
    "path": "api/src/processing/services/instagram.js",
    "content": "import { randomBytes } from \"node:crypto\";\nimport { resolveRedirectingURL } from \"../url.js\";\nimport { genericUserAgent } from \"../../config.js\";\nimport { createStream } from \"../../stream/manage.js\";\nimport { getCookie, updateCookie } from \"../cookie/manager.js\";\n\nconst commonHeaders = {\n    \"user-agent\": genericUserAgent,\n    \"sec-gpc\": \"1\",\n    \"sec-fetch-site\": \"same-origin\",\n    \"x-ig-app-id\": \"936619743392459\"\n}\n\nconst mobileHeaders = {\n    \"x-ig-app-locale\": \"en_US\",\n    \"x-ig-device-locale\": \"en_US\",\n    \"x-ig-mapped-locale\": \"en_US\",\n    \"user-agent\": \"Instagram 275.0.0.27.98 Android (33/13; 280dpi; 720x1423; Xiaomi; Redmi 7; onclite; qcom; en_US; 458229237)\",\n    \"accept-language\": \"en-US\",\n    \"x-fb-http-engine\": \"Liger\",\n    \"x-fb-client-ip\": \"True\",\n    \"x-fb-server-cluster\": \"True\",\n    \"content-length\": \"0\",\n}\n\nconst embedHeaders = {\n    \"Accept\": \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\",\n    \"Accept-Language\": \"en-GB,en;q=0.9\",\n    \"Cache-Control\": \"max-age=0\",\n    \"Dnt\": \"1\",\n    \"Priority\": \"u=0, i\",\n    \"Sec-Ch-Ua\": 'Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99',\n    \"Sec-Ch-Ua-Mobile\": \"?0\",\n    \"Sec-Ch-Ua-Platform\": \"macOS\",\n    \"Sec-Fetch-Dest\": \"document\",\n    \"Sec-Fetch-Mode\": \"navigate\",\n    \"Sec-Fetch-Site\": \"none\",\n    \"Sec-Fetch-User\": \"?1\",\n    \"Upgrade-Insecure-Requests\": \"1\",\n    \"User-Agent\": genericUserAgent,\n}\n\nconst cachedDtsg = {\n    value: '',\n    expiry: 0\n}\n\nconst getNumberFromQuery = (name, data) => {\n    const s = data?.match(new RegExp(name + '=(\\\\d+)'))?.[1];\n    if (+s) return +s;\n}\n\nconst getObjectFromEntries = (name, data) => {\n    const obj = data?.match(new RegExp('\\\\[\"' + name + '\",.*?,({.*?}),\\\\d+\\\\]'))?.[1];\n    return obj && JSON.parse(obj);\n}\n\nexport default function instagram(obj) {\n    const dispatcher = obj.dispatcher;\n\n    async function findDtsgId(cookie) {\n        try {\n            if (cachedDtsg.expiry > Date.now()) return cachedDtsg.value;\n\n            const data = await fetch('https://www.instagram.com/', {\n                headers: {\n                    ...commonHeaders,\n                    cookie\n                },\n                dispatcher\n            }).then(r => r.text());\n\n            const token = data.match(/\"dtsg\":{\"token\":\"(.*?)\"/)[1];\n\n            cachedDtsg.value = token;\n            cachedDtsg.expiry = Date.now() + 86390000;\n\n            if (token) return token;\n            return false;\n        }\n        catch {}\n    }\n\n    async function request(url, cookie, method = 'GET', requestData) {\n        let headers = {\n            ...commonHeaders,\n            'x-ig-www-claim': cookie?._wwwClaim || '0',\n            'x-csrftoken': cookie?.values()?.csrftoken,\n            cookie\n        }\n        if (method === 'POST') {\n            headers['content-type'] = 'application/x-www-form-urlencoded';\n        }\n\n        const data = await fetch(url, {\n            method,\n            headers,\n            body: requestData && new URLSearchParams(requestData),\n            dispatcher\n        });\n\n        if (data.headers.get('X-Ig-Set-Www-Claim') && cookie)\n            cookie._wwwClaim = data.headers.get('X-Ig-Set-Www-Claim');\n\n        updateCookie(cookie, data.headers);\n        return data.json();\n    }\n\n    async function getMediaId(id, { cookie, token } = {}) {\n        const oembedURL = new URL('https://i.instagram.com/api/v1/oembed/');\n        oembedURL.searchParams.set('url', `https://www.instagram.com/p/${id}/`);\n\n        const oembed = await fetch(oembedURL, {\n            headers: {\n                ...mobileHeaders,\n                ...( token && { authorization: `Bearer ${token}` } ),\n                cookie\n            },\n            dispatcher\n        }).then(r => r.json()).catch(() => {});\n\n        return oembed?.media_id;\n    }\n\n    async function requestMobileApi(mediaId, { cookie, token } = {}) {\n        const mediaInfo = await fetch(`https://i.instagram.com/api/v1/media/${mediaId}/info/`, {\n            headers: {\n                ...mobileHeaders,\n                ...( token && { authorization: `Bearer ${token}` } ),\n                cookie\n            },\n            dispatcher\n        }).then(r => r.json()).catch(() => {});\n\n        return mediaInfo?.items?.[0];\n    }\n\n    async function requestHTML(id, cookie) {\n        const data = await fetch(`https://www.instagram.com/p/${id}/embed/captioned/`, {\n            headers: {\n                ...embedHeaders,\n                cookie\n            },\n            dispatcher\n        }).then(r => r.text()).catch(() => {});\n\n        let embedData = JSON.parse(data?.match(/\"init\",\\[\\],\\[(.*?)\\]\\],/)[1]);\n\n        if (!embedData || !embedData?.contextJSON) return false;\n\n        embedData = JSON.parse(embedData.contextJSON);\n\n        return embedData;\n    }\n\n    async function getGQLParams(id, cookie) {\n        const req = await fetch(`https://www.instagram.com/p/${id}/`, {\n            headers: {\n                ...embedHeaders,\n                cookie\n            },\n            dispatcher\n        });\n\n        const html = await req.text();\n        const siteData = getObjectFromEntries('SiteData', html);\n        const polarisSiteData = getObjectFromEntries('PolarisSiteData', html);\n        const webConfig = getObjectFromEntries('DGWWebConfig', html);\n        const pushInfo = getObjectFromEntries('InstagramWebPushInfo', html);\n        const lsd = getObjectFromEntries('LSD', html)?.token || randomBytes(8).toString('base64url');\n        const csrf = getObjectFromEntries('InstagramSecurityConfig', html)?.csrf_token;\n\n        const anon_cookie = [\n            csrf && \"csrftoken=\" + csrf,\n            polarisSiteData?.device_id && \"ig_did=\" + polarisSiteData?.device_id,\n            \"wd=1280x720\",\n            \"dpr=2\",\n            polarisSiteData?.machine_id && \"mid=\" + polarisSiteData.machine_id,\n            \"ig_nrcb=1\"\n        ].filter(a => a).join('; ');\n\n        return {\n            headers: {\n                'x-ig-app-id': webConfig?.appId || '936619743392459',\n                'X-FB-LSD': lsd,\n                'X-CSRFToken': csrf,\n                'X-Bloks-Version-Id': getObjectFromEntries('WebBloksVersioningID', html)?.versioningID,\n                'x-asbd-id': 129477,\n                cookie: anon_cookie\n            },\n            body: {\n                __d: 'www',\n                __a: '1',\n                __s: '::' + Math.random().toString(36).substring(2).replace(/\\d/g, '').slice(0, 6),\n                __hs: siteData?.haste_session || '20126.HYP:instagram_web_pkg.2.1...0',\n                __req: 'b',\n                __ccg: 'EXCELLENT',\n                __rev: pushInfo?.rollout_hash || '1019933358',\n                __hsi: siteData?.hsi || '7436540909012459023',\n                __dyn: randomBytes(154).toString('base64url'),\n                __csr: randomBytes(154).toString('base64url'),\n                __user: '0',\n                __comet_req: getNumberFromQuery('__comet_req', html) || '7',\n                av: '0',\n                dpr: '2',\n                lsd,\n                jazoest: getNumberFromQuery('jazoest', html) || Math.floor(Math.random() * 10000),\n                __spin_r: siteData?.__spin_r || '1019933358',\n                __spin_b: siteData?.__spin_b || 'trunk',\n                __spin_t: siteData?.__spin_t || Math.floor(new Date().getTime() / 1000),\n            }\n        };\n    }\n\n    async function requestGQL(id, cookie) {\n        const { headers, body } = await getGQLParams(id, cookie);\n\n        const req = await fetch('https://www.instagram.com/graphql/query', {\n            method: 'POST',\n            dispatcher,\n            headers: {\n                ...embedHeaders,\n                ...headers,\n                cookie,\n                'content-type': 'application/x-www-form-urlencoded',\n                'X-FB-Friendly-Name': 'PolarisPostActionLoadPostQueryQuery',\n            },\n            body: new URLSearchParams({\n                ...body,\n                fb_api_caller_class: 'RelayModern',\n                fb_api_req_friendly_name: 'PolarisPostActionLoadPostQueryQuery',\n                variables: JSON.stringify({\n                    shortcode: id,\n                    fetch_tagged_user_count: null,\n                    hoisted_comment_id: null,\n                    hoisted_reply_id: null\n                }),\n                server_timestamps: true,\n                doc_id: '8845758582119845'\n            }).toString()\n        });\n\n        return {\n            gql_data: await req.json()\n                        .then(r => r.data)\n                        .catch(() => null)\n        };\n    }\n\n    async function getErrorContext(id) {\n        try {\n            const { headers, body } = await getGQLParams(id);\n\n            const req = await fetch('https://www.instagram.com/ajax/bulk-route-definitions/', {\n                method: 'POST',\n                dispatcher,\n                headers: {\n                    ...embedHeaders,\n                    ...headers,\n                    'content-type': 'application/x-www-form-urlencoded',\n                    'X-Ig-D': 'www',\n                },\n                body: new URLSearchParams({\n                    'route_urls[0]': `/p/${id}/`,\n                    routing_namespace: 'igx_www',\n                    ...body\n                }).toString()\n            });\n\n            const response = await req.text();\n            if (response.includes('\"tracePolicy\":\"polaris.privatePostPage\"'))\n                return { error: 'content.post.private' };\n\n            const [, mediaId, mediaOwnerId] = response.match(\n                /\"media_id\":\\s*?\"(\\d+)\",\"media_owner_id\":\\s*?\"(\\d+)\"/\n            ) || [];\n\n            if (mediaId && mediaOwnerId) {\n                const rulingURL = new URL('https://www.instagram.com/api/v1/web/get_ruling_for_media_content_logged_out');\n                rulingURL.searchParams.set('media_id', mediaId);\n                rulingURL.searchParams.set('owner_id', mediaOwnerId);\n\n                const rulingResponse = await fetch(rulingURL, {\n                    headers: {\n                        ...headers,\n                        ...commonHeaders\n                    },\n                    dispatcher,\n                }).then(a => a.json()).catch(() => ({}));\n\n                if (rulingResponse?.title?.includes('Restricted'))\n                    return { error: \"content.post.age\" };\n            }\n        } catch {\n            return { error: \"fetch.fail\" };\n        }\n\n        return { error: \"fetch.empty\" };\n    }\n\n    function extractOldPost(data, id, alwaysProxy) {\n        const shortcodeMedia = data?.gql_data?.shortcode_media || data?.gql_data?.xdt_shortcode_media;\n        const sidecar = shortcodeMedia?.edge_sidecar_to_children;\n\n        if (sidecar) {\n            const picker = sidecar.edges.filter(e => e.node?.display_url)\n                .map((e, i) => {\n                    const type = e.node?.is_video && e.node?.video_url ? \"video\" : \"photo\";\n\n                    let url;\n                    if (type === \"video\") {\n                        url = e.node?.video_url;\n                    } else if (type === \"photo\") {\n                        url = e.node?.display_url;\n                    }\n\n                    let itemExt = type === \"video\" ? \"mp4\" : \"jpg\";\n\n                    let proxyFile;\n                    if (alwaysProxy) proxyFile = createStream({\n                        service: \"instagram\",\n                        type: \"proxy\",\n                        url,\n                        filename: `instagram_${id}_${i + 1}.${itemExt}`\n                    });\n\n                    return {\n                        type,\n                        url: proxyFile || url,\n                        /* thumbnails have `Cross-Origin-Resource-Policy`\n                        ** set to `same-origin`, so we need to proxy them */\n                        thumb: createStream({\n                            service: \"instagram\",\n                            type: \"proxy\",\n                            url: e.node?.display_url,\n                            filename: `instagram_${id}_${i + 1}.jpg`\n                        })\n                    }\n                });\n\n            if (picker.length) return { picker }\n        }\n\n        if (shortcodeMedia?.video_url) {\n            return {\n                urls: shortcodeMedia.video_url,\n                filename: `instagram_${id}.mp4`,\n                audioFilename: `instagram_${id}_audio`\n            }\n        }\n\n        if (shortcodeMedia?.display_url) {\n            return {\n                urls: shortcodeMedia.display_url,\n                isPhoto: true,\n                filename: `instagram_${id}.jpg`,\n            }\n        }\n    }\n\n    function extractNewPost(data, id, alwaysProxy) {\n        const carousel = data.carousel_media;\n        if (carousel) {\n            const picker = carousel.filter(e => e?.image_versions2)\n                .map((e, i) => {\n                    const type = e.video_versions ? \"video\" : \"photo\";\n                    const imageUrl = e.image_versions2.candidates[0].url;\n\n                    let url = imageUrl;\n                    let itemExt = type === \"video\" ? \"mp4\" : \"jpg\";\n\n                    if (type === \"video\") {\n                        const video = e.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a);\n                        url = video.url;\n                    }\n\n                    let proxyFile;\n                    if (alwaysProxy) proxyFile = createStream({\n                        service: \"instagram\",\n                        type: \"proxy\",\n                        url,\n                        filename: `instagram_${id}_${i + 1}.${itemExt}`\n                    });\n\n                    return {\n                        type,\n                        url: proxyFile || url,\n                        /* thumbnails have `Cross-Origin-Resource-Policy`\n                        ** set to `same-origin`, so we need to always proxy them */\n                        thumb: createStream({\n                            service: \"instagram\",\n                            type: \"proxy\",\n                            url: imageUrl,\n                            filename: `instagram_${id}_${i + 1}.jpg`\n                        })\n                    }\n                });\n\n            if (picker.length) return { picker }\n        } else if (data.video_versions) {\n            const video = data.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a)\n            return {\n                urls: video.url,\n                filename: `instagram_${id}.mp4`,\n                audioFilename: `instagram_${id}_audio`\n            }\n        } else if (data.image_versions2?.candidates) {\n            return {\n                urls: data.image_versions2.candidates[0].url,\n                isPhoto: true,\n                filename: `instagram_${id}.jpg`,\n            }\n        }\n    }\n\n    async function getPost(id, alwaysProxy) {\n        const hasData = (data) => data\n                                    && data.gql_data !== null\n                                    && data?.gql_data?.xdt_shortcode_media !== null;\n        let data, result;\n        try {\n            const cookie = getCookie('instagram');\n\n            const bearer = getCookie('instagram_bearer');\n            const token = bearer?.values()?.token;\n\n            // get media_id for mobile api, three methods\n            let media_id = await getMediaId(id);\n            if (!media_id && token) media_id = await getMediaId(id, { token });\n            if (!media_id && cookie) media_id = await getMediaId(id, { cookie });\n\n            // mobile api (bearer)\n            if (media_id && token) data = await requestMobileApi(media_id, { token });\n\n            // mobile api (no cookie, cookie)\n            if (media_id && !hasData(data)) data = await requestMobileApi(media_id);\n            if (media_id && cookie && !hasData(data)) data = await requestMobileApi(media_id, { cookie });\n\n            // html embed (no cookie, cookie)\n            if (!hasData(data)) data = await requestHTML(id);\n            if (!hasData(data) && cookie) data = await requestHTML(id, cookie);\n\n            // web app graphql api (no cookie, cookie)\n            if (!hasData(data)) data = await requestGQL(id);\n            if (!hasData(data) && cookie) data = await requestGQL(id, cookie);\n        } catch {}\n\n        if (!hasData(data)) {\n            return getErrorContext(id);\n        }\n\n        if (data?.gql_data) {\n            result = extractOldPost(data, id, alwaysProxy)\n        } else {\n            result = extractNewPost(data, id, alwaysProxy)\n        }\n\n        if (result) return result;\n        return { error: \"fetch.empty\" }\n    }\n\n    async function usernameToId(username, cookie) {\n        const url = new URL('https://www.instagram.com/api/v1/users/web_profile_info/');\n            url.searchParams.set('username', username);\n\n        try {\n            const data = await request(url, cookie);\n            return data?.data?.user?.id;\n        } catch {}\n    }\n\n    async function getStory(username, id) {\n        const cookie = getCookie('instagram');\n        if (!cookie) return { error: \"link.unsupported\" };\n\n        const userId = await usernameToId(username, cookie);\n        if (!userId) return { error: \"fetch.empty\" };\n\n        const dtsgId = await findDtsgId(cookie);\n\n        const url = new URL('https://www.instagram.com/api/graphql/');\n        const requestData = {\n            fb_dtsg: dtsgId,\n            jazoest: '26438',\n            variables: JSON.stringify({\n                reel_ids_arr : [ userId ],\n            }),\n            server_timestamps: true,\n            doc_id: '25317500907894419'\n        };\n\n        let media;\n        try {\n            const data = (await request(url, cookie, 'POST', requestData));\n            media = data?.data?.xdt_api__v1__feed__reels_media?.reels_media?.find(m => m.id === userId);\n        } catch {}\n\n        const item = media.items.find(m => m.pk === id);\n        if (!item) return { error: \"fetch.empty\" };\n\n        if (item.video_versions) {\n            const video = item.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a)\n            return {\n                urls: video.url,\n                filename: `instagram_${id}.mp4`,\n                audioFilename: `instagram_${id}_audio`\n            }\n        }\n\n        if (item.image_versions2?.candidates) {\n            return {\n                urls: item.image_versions2.candidates[0].url,\n                isPhoto: true,\n                filename: `instagram_${id}.jpg`,\n            }\n        }\n\n        return { error: \"link.unsupported\" };\n    }\n\n    const { postId, shareId, storyId, username, alwaysProxy } = obj;\n\n    if (shareId) {\n        return resolveRedirectingURL(\n            `https://www.instagram.com/share/${shareId}/`,\n            dispatcher,\n            // for some reason instagram decides to return HTML\n            // instead of a redirect when requesting with a normal\n            // browser user-agent\n            {'User-Agent': 'curl/7.88.1'}\n        ).then(match => instagram({\n            ...obj, ...match,\n            shareId: undefined\n        }));\n    }\n\n    if (postId) return getPost(postId, alwaysProxy);\n    if (username && storyId) return getStory(username, storyId);\n\n    return { error: \"fetch.empty\" }\n}\n"
  },
  {
    "path": "api/src/processing/services/loom.js",
    "content": "import { genericUserAgent } from \"../../config.js\";\n\nconst craftHeaders = id => ({\n    \"user-agent\": genericUserAgent,\n    \"content-type\": \"application/json\",\n    origin: \"https://www.loom.com\",\n    referer: `https://www.loom.com/share/${id}`,\n    cookie: `loom_referral_video=${id};`,\n    \"x-loom-request-source\": \"loom_web_be851af\",\n});\n\nasync function fromTranscodedURL(id) {\n    const gql = await fetch(`https://www.loom.com/api/campaigns/sessions/${id}/transcoded-url`, {\n        method: \"POST\",\n        headers: craftHeaders(id),\n        body: JSON.stringify({\n            force_original: false,\n            password: null,\n            anonID: null,\n            deviceID: null\n        })\n    })\n    .then(r => r.status === 200 && r.json())\n    .catch(() => {});\n\n    if (gql?.url?.includes('.mp4?')) {\n        return gql.url;\n    }\n}\n\nasync function fromRawURL(id) {\n    const gql = await fetch(`https://www.loom.com/api/campaigns/sessions/${id}/raw-url`, {\n        method: \"POST\",\n        headers: craftHeaders(id),\n        body: JSON.stringify({\n            anonID: crypto.randomUUID(),\n            client_name: \"web\",\n            client_version: \"be851af\",\n            deviceID: null,\n            force_original: false,\n            password: null,\n            supported_mime_types: [\"video/mp4\"],\n        })\n    })\n    .then(r => r.status === 200 && r.json())\n    .catch(() => {});\n\n    if (gql?.url?.includes('.mp4?')) {\n        return gql.url;\n    }\n}\n\nasync function getTranscript(id) {\n    const gql = await fetch(`https://www.loom.com/graphql`, {\n        method: \"POST\",\n        headers: craftHeaders(id),\n        body: JSON.stringify({\n            operationName: \"FetchVideoTranscriptForFetchTranscript\",\n            variables: {\n                videoId: id,\n                password: null,\n            },\n            query: `\n                query FetchVideoTranscriptForFetchTranscript($videoId: ID!, $password: String) {\n                    fetchVideoTranscript(videoId: $videoId, password: $password) {\n                        ... on VideoTranscriptDetails {\n                        captions_source_url\n                        language\n                        __typename\n                        }\n                        ... on GenericError {\n                        message\n                        __typename\n                        }\n                        __typename\n                    }\n                }`,\n        })\n    })\n    .then(r => r.status === 200 && r.json())\n    .catch(() => {});\n\n    if (gql?.data?.fetchVideoTranscript?.captions_source_url?.includes('.vtt?')) {\n        return gql.data.fetchVideoTranscript.captions_source_url;\n    }\n}\n\nexport default async function({ id, subtitleLang }) {\n    let url = await fromTranscodedURL(id);\n    url ??= await fromRawURL(id);\n\n    if (!url) {\n        return { error: \"fetch.empty\" }\n    }\n\n    let subtitles;\n    if (subtitleLang) {\n        const transcript = await getTranscript(id);\n        if (transcript) subtitles = transcript;\n    }\n\n    return {\n        urls: url,\n        subtitles,\n        filename: `loom_${id}.mp4`,\n        audioFilename: `loom_${id}_audio`\n    }\n}\n"
  },
  {
    "path": "api/src/processing/services/newgrounds.js",
    "content": "import { genericUserAgent } from \"../../config.js\";\n\nconst getVideo = async ({ id, quality }) => {\n    const json = await fetch(`https://www.newgrounds.com/portal/video/${id}`, {\n        headers: {\n            \"User-Agent\": genericUserAgent,\n            \"X-Requested-With\": \"XMLHttpRequest\", // required to get the JSON response\n        }\n    })\n    .then(r => r.json())\n    .catch(() => {});\n\n    if (!json) return { error: \"fetch.empty\" };\n\n    const videoSources = json.sources;\n    const videoQualities = Object.keys(videoSources);\n\n    if (videoQualities.length === 0) {\n        return { error: \"fetch.empty\" };\n    }\n\n    const bestVideo = videoSources[videoQualities[0]]?.[0],\n          userQuality = quality === \"2160\" ? \"4k\" : `${quality}p`,\n          preferredVideo = videoSources[userQuality]?.[0],\n          video = preferredVideo || bestVideo,\n          videoQuality = preferredVideo ? userQuality : videoQualities[0];\n\n    if (!bestVideo || !video.type.includes(\"mp4\")) {\n        return { error: \"fetch.empty\" };\n    }\n\n    const fileMetadata = {\n        title: decodeURIComponent(json.title),\n        artist: decodeURIComponent(json.author),\n    }\n\n    return {\n        urls: video.src,\n        filenameAttributes: {\n            service: \"newgrounds\",\n            id,\n            title: fileMetadata.title,\n            author: fileMetadata.artist,\n            extension: \"mp4\",\n            qualityLabel: videoQuality,\n            resolution: videoQuality,\n        },\n        fileMetadata,\n    }\n}\n\nconst getMusic = async ({ id }) => {\n    const html = await fetch(`https://www.newgrounds.com/audio/listen/${id}`, {\n        headers: {\n            \"User-Agent\": genericUserAgent,\n        }\n    })\n    .then(r => r.text())\n    .catch(() => {});\n\n    if (!html) return { error: \"fetch.fail\" };\n\n    const params = JSON.parse(\n        `{${html.split(',\"params\":{')[1]?.split(',\"images\":')[0]}}`\n    );\n    if (!params) return { error: \"fetch.empty\" };\n\n    if (!params.name || !params.artist || !params.filename || !params.icon) {\n        return { error: \"fetch.empty\" };\n    }\n\n    const fileMetadata = {\n        title: decodeURIComponent(params.name),\n        artist: decodeURIComponent(params.artist),\n    }\n\n    return {\n        urls: params.filename,\n        filenameAttributes: {\n            service: \"newgrounds\",\n            id,\n            title: fileMetadata.title,\n            author: fileMetadata.artist,\n        },\n        fileMetadata,\n        cover:\n            params.icon.includes(\".png?\") || params.icon.includes(\".jpg?\")\n                ? params.icon\n                : undefined,\n        isAudioOnly: true,\n        bestAudio: \"mp3\",\n    }\n}\n\nexport default function({ id, audioId, quality }) {\n    if (id) {\n        return getVideo({ id, quality });\n    } else if (audioId) {\n        return getMusic({ id: audioId });\n    }\n\n    return { error: \"fetch.empty\" };\n}\n"
  },
  {
    "path": "api/src/processing/services/ok.js",
    "content": "import { genericUserAgent, env } from \"../../config.js\";\n\nconst resolutions = {\n    \"ultra\": \"2160\",\n    \"quad\": \"1440\",\n    \"full\": \"1080\",\n    \"hd\": \"720\",\n    \"sd\": \"480\",\n    \"low\": \"360\",\n    \"lowest\": \"240\",\n    \"mobile\": \"144\"\n}\n\nexport default async function(o) {\n    let quality = o.quality === \"max\" ? \"2160\" : o.quality;\n\n    let html = await fetch(`https://ok.ru/video/${o.id}`, {\n        headers: { \"user-agent\": genericUserAgent }\n    }).then(r => r.text()).catch(() => {});\n\n    if (!html) return { error: \"fetch.fail\" };\n\n    let videoData = html.match(/<div data-module=\"OKVideo\" .*? data-options=\"({.*?})\"( .*?)>/)\n                        ?.[1]\n                        ?.replaceAll(\"&quot;\", '\"');\n\n    if (!videoData) {\n        return { error: \"fetch.empty\" };\n    }\n\n    videoData = JSON.parse(JSON.parse(videoData).flashvars.metadata);\n\n    if (videoData.provider !== \"UPLOADED_ODKL\")\n        return { error: \"link.unsupported\" };\n\n    if (videoData.movie.is_live)\n        return { error: \"content.video.live\" };\n\n    if (videoData.movie.duration > env.durationLimit)\n        return { error: \"content.too_long\" };\n\n    let videos = videoData.videos.filter(v => !v.disallowed);\n    let bestVideo = videos.find(v => resolutions[v.name] === quality) || videos[videos.length - 1];\n\n    let fileMetadata = {\n        title: videoData.movie.title.trim(),\n        author: (videoData.author?.name || videoData.compilationTitle)?.trim(),\n    }\n\n    if (bestVideo) return {\n        urls: bestVideo.url,\n        filenameAttributes: {\n            service: \"ok\",\n            id: o.id,\n            title: fileMetadata.title,\n            author: fileMetadata.author,\n            resolution: `${resolutions[bestVideo.name]}p`,\n            qualityLabel: `${resolutions[bestVideo.name]}p`,\n            extension: \"mp4\"\n        }\n    }\n\n    return { error: \"fetch.empty\" }\n}\n"
  },
  {
    "path": "api/src/processing/services/pinterest.js",
    "content": "import { genericUserAgent } from \"../../config.js\";\nimport { resolveRedirectingURL } from \"../url.js\";\n\nconst videoRegex = /\"url\":\"(https:\\/\\/v1\\.pinimg\\.com\\/videos\\/.*?)\"/g;\nconst imageRegex = /src=\"(https:\\/\\/i\\.pinimg\\.com\\/.*\\.(jpg|gif))\"/g;\nconst notFoundRegex = /\"__typename\"\\s*:\\s*\"PinNotFound\"/;\n\nexport default async function(o) {\n    let id = o.id;\n\n    if (!o.id && o.shortLink) {\n        const patternMatch = await resolveRedirectingURL(`https://api.pinterest.com/url_shortener/${o.shortLink}/redirect/`);\n        id = patternMatch?.id;\n    }\n\n    if (id.includes(\"--\")) id = id.split(\"--\")[1];\n    if (!id) return { error: \"fetch.fail\" };\n\n    const html = await fetch(`https://www.pinterest.com/pin/${id}/`, {\n        headers: { \"user-agent\": genericUserAgent }\n    }).then(r => r.text()).catch(() => {});\n\n    const invalidPin = html.match(notFoundRegex);\n\n    if (invalidPin) return { error: \"fetch.empty\" };\n\n    if (!html) return { error: \"fetch.fail\" };\n\n    const videoLink = [...html.matchAll(videoRegex)]\n                    .map(([, link]) => link)\n                    .find(a => a.endsWith('.mp4'));\n\n    if (videoLink) return {\n        urls: videoLink,\n        filename: `pinterest_${id}.mp4`,\n        audioFilename: `pinterest_${id}_audio`\n    }\n\n    const imageLink = [...html.matchAll(imageRegex)]\n                    .map(([, link]) => link)\n                    .find(a => a.endsWith('.jpg') || a.endsWith('.gif'));\n\n    const imageType = imageLink.endsWith(\".gif\") ? \"gif\" : \"jpg\"\n\n    if (imageLink) return {\n        urls: imageLink,\n        isPhoto: true,\n        filename: `pinterest_${id}.${imageType}`\n    }\n\n    return { error: \"fetch.empty\" };\n}\n"
  },
  {
    "path": "api/src/processing/services/reddit.js",
    "content": "import { resolveRedirectingURL } from \"../url.js\";\nimport { genericUserAgent, env } from \"../../config.js\";\nimport { getCookie, updateCookieValues } from \"../cookie/manager.js\";\n\nasync function getAccessToken() {\n    /* \"cookie\" in cookiefile needs to contain:\n     * client_id, client_secret, refresh_token\n     * e.g. client_id=bla; client_secret=bla; refresh_token=bla\n     *\n     * you can get these by making a reddit app and\n     * authenticating an account against reddit's oauth2 api\n     * see: https://github.com/reddit-archive/reddit/wiki/OAuth2\n     *\n     * any additional cookie fields are managed by this code and you\n     * should not touch them unless you know what you're doing. **/\n    const cookie = await getCookie('reddit');\n    if (!cookie) return;\n\n    const values = cookie.values(),\n          needRefresh = !values.access_token\n                        || !values.expiry\n                        || Number(values.expiry) < new Date().getTime();\n    if (!needRefresh) return values.access_token;\n\n    const data = await fetch('https://www.reddit.com/api/v1/access_token', {\n        method: 'POST',\n        headers: {\n            'authorization': `Basic ${Buffer.from(\n                [values.client_id, values.client_secret].join(':')\n            ).toString('base64')}`,\n            'content-type': 'application/x-www-form-urlencoded',\n            'user-agent': genericUserAgent,\n            'accept': 'application/json'\n        },\n        body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(values.refresh_token)}`\n    }).then(r => r.json()).catch(() => {});\n    if (!data) return;\n\n    const { access_token, refresh_token, expires_in } = data;\n    if (!access_token) return;\n\n    updateCookieValues(cookie, {\n        ...cookie.values(),\n        access_token, refresh_token,\n        expiry: new Date().getTime() + (expires_in * 1000),\n    });\n\n    return access_token;\n}\n\nexport default async function(obj) {\n    let params = obj;\n    const accessToken = await getAccessToken();\n    const headers = {\n        'user-agent': genericUserAgent,\n        authorization: accessToken && `Bearer ${accessToken}`,\n        accept: 'application/json'\n    };\n\n    if (params.shortId) {\n        params = await resolveRedirectingURL(\n            `https://www.reddit.com/video/${params.shortId}`,\n            obj.dispatcher, headers\n        );\n    }\n\n    if (!params.id && params.shareId) {\n        params = await resolveRedirectingURL(\n            `https://www.reddit.com/r/${params.sub}/s/${params.shareId}`,\n            obj.dispatcher, headers\n        );\n    }\n\n    if (!params?.id) return { error: \"fetch.short_link\" };\n\n    const url = new URL(`https://www.reddit.com/comments/${params.id}.json`);\n\n    if (accessToken) url.hostname = 'oauth.reddit.com';\n\n    let data = await fetch(\n        url, { headers }\n    ).then(r => r.json()).catch(() => {});\n\n    if (!data || !Array.isArray(data)) {\n        return { error: \"fetch.fail\" }\n    }\n\n    data = data[0]?.data?.children[0]?.data;\n\n    let sourceId;\n    if (params.sub || params.user) {\n        sourceId = `${String(params.sub || params.user).toLowerCase()}_${params.id}`;\n    } else {\n        sourceId = params.id;\n    }\n\n    if (data?.url?.endsWith('.gif')) return {\n        typeId: \"redirect\",\n        urls: data.url,\n        filename: `reddit_${sourceId}.gif`,\n    }\n\n    if (!data.secure_media?.reddit_video)\n        return { error: \"fetch.empty\" };\n\n    if (data.secure_media?.reddit_video?.duration > env.durationLimit)\n        return { error: \"content.too_long\" };\n\n    const video = data.secure_media?.reddit_video?.fallback_url?.split('?')[0];\n\n    let audio = false,\n        audioFileLink = `${data.secure_media?.reddit_video?.fallback_url?.split('DASH')[0]}audio`;\n\n    if (video.match('.mp4')) {\n        audioFileLink = `${video.split('_')[0]}_audio.mp4`\n    }\n\n    // test the existence of audio\n    await fetch(audioFileLink, { method: \"HEAD\" }).then(r => {\n        if (Number(r.status) === 200) {\n            audio = true\n        }\n    }).catch(() => {})\n\n    // fallback for videos with variable audio quality\n    if (!audio) {\n        audioFileLink = `${video.split('_')[0]}_AUDIO_128.mp4`\n        await fetch(audioFileLink, { method: \"HEAD\" }).then(r => {\n            if (Number(r.status) === 200) {\n                audio = true\n            }\n        }).catch(() => {})\n    }\n\n    if (!audio) return {\n        typeId: \"redirect\",\n        urls: video\n    }\n\n    return {\n        typeId: \"tunnel\",\n        type: \"merge\",\n        urls: [video, audioFileLink],\n        audioFilename: `reddit_${sourceId}_audio`,\n        filename: `reddit_${sourceId}.mp4`\n    }\n}\n"
  },
  {
    "path": "api/src/processing/services/rutube.js",
    "content": "import HLS from \"hls-parser\";\nimport { env } from \"../../config.js\";\n\nasync function requestJSON(url) {\n    try {\n        const r = await fetch(url);\n        return await r.json();\n    } catch {}\n}\n\nconst delta = (a, b) => Math.abs(a - b);\n\nexport default async function(obj) {\n    if (obj.yappyId) {\n        const yappy = await requestJSON(\n            `https://rutube.ru/pangolin/api/web/yappy/yappypage/?client=wdp&videoId=${obj.yappyId}&page=1&page_size=15`\n        )\n        const yappyURL = yappy?.results?.find(r => r.id === obj.yappyId)?.link;\n        if (!yappyURL) return { error: \"fetch.empty\" };\n\n        return {\n            urls: yappyURL,\n            filename: `rutube_yappy_${obj.yappyId}.mp4`,\n            audioFilename: `rutube_yappy_${obj.yappyId}_audio`\n        }\n    }\n\n    const quality = Number(obj.quality) || 9000;\n\n    const requestURL = new URL(`https://rutube.ru/api/play/options/${obj.id}/?no_404=true&referer&pver=v2`);\n    if (obj.key) requestURL.searchParams.set('p', obj.key);\n\n    const play = await requestJSON(requestURL);\n    if (!play) return { error: \"fetch.fail\" };\n\n    if (play.detail?.type === \"blocking_rule\") {\n        return { error: \"content.video.region\" };\n    }\n\n    if (play.detail || !play.video_balancer) return { error: \"fetch.empty\" };\n    if (play.live_streams?.hls) return { error: \"content.video.live\" };\n\n    if (play.duration > env.durationLimit * 1000)\n        return { error: \"content.too_long\" };\n\n    let m3u8 = await fetch(play.video_balancer.m3u8)\n                     .then(r => r.text())\n                     .catch(() => {});\n\n    if (!m3u8) return { error: \"fetch.fail\" };\n\n    m3u8 = HLS.parse(m3u8).variants;\n\n    const matchingQuality = m3u8.reduce((prev, next) => {\n        const diff = {\n            prev: delta(quality, prev.resolution.height),\n            next: delta(quality, next.resolution.height)\n        };\n\n        return diff.prev < diff.next ? prev : next;\n    });\n\n    const fileMetadata = {\n        title: play.title.trim(),\n        artist: play.author.name.trim(),\n    }\n\n    let subtitles;\n    if (obj.subtitleLang && play.captions?.length) {\n        const subtitle = play.captions.find(\n            s => [\"webvtt\", \"srt\"].includes(s.format) && s.code.startsWith(obj.subtitleLang)\n        );\n\n        if (subtitle) {\n            subtitles = subtitle.file;\n            fileMetadata.sublanguage = obj.subtitleLang;\n        }\n    }\n\n    return {\n        urls: matchingQuality.uri,\n        subtitles,\n        isHLS: true,\n        filenameAttributes: {\n            service: \"rutube\",\n            id: obj.id,\n            title: fileMetadata.title,\n            author: fileMetadata.artist,\n            resolution: `${matchingQuality.resolution.width}x${matchingQuality.resolution.height}`,\n            qualityLabel: `${matchingQuality.resolution.height}p`,\n            extension: \"mp4\"\n        },\n        fileMetadata: fileMetadata\n    }\n}\n"
  },
  {
    "path": "api/src/processing/services/snapchat.js",
    "content": "import { resolveRedirectingURL } from \"../url.js\";\nimport { genericUserAgent } from \"../../config.js\";\nimport { createStream } from \"../../stream/manage.js\";\n\nconst SPOTLIGHT_VIDEO_REGEX = /<link data-react-helmet=\"true\" rel=\"preload\" href=\"([^\"]+)\" as=\"video\"\\/>/;\nconst NEXT_DATA_REGEX = /<script id=\"__NEXT_DATA__\" type=\"application\\/json\">({.+})<\\/script><\\/body><\\/html>$/;\n\nasync function getSpotlight(id) {\n    const html = await fetch(`https://www.snapchat.com/spotlight/${id}`, {\n        headers: { 'user-agent': genericUserAgent }\n    }).then((r) => r.text()).catch(() => null);\n\n    if (!html) {\n        return { error: \"fetch.fail\" };\n    }\n\n    const videoURL = html.match(SPOTLIGHT_VIDEO_REGEX)?.[1];\n\n    if (videoURL && new URL(videoURL).hostname.endsWith(\".sc-cdn.net\")) {\n        return {\n            urls: videoURL,\n            filename: `snapchat_${id}.mp4`,\n            audioFilename: `snapchat_${id}_audio`\n        }\n    }\n}\n\nasync function getStory(username, storyId, alwaysProxy) {\n    const html = await fetch(\n        `https://www.snapchat.com/add/${username}${storyId ? `/${storyId}` : ''}`,\n        { headers: { 'user-agent': genericUserAgent } }\n    )\n    .then((r) => r.text())\n    .catch(() => null);\n\n    if (!html) {\n        return { error: \"fetch.fail\" };\n    }\n\n    const nextDataString = html.match(NEXT_DATA_REGEX)?.[1];\n    if (nextDataString) {\n        const data = JSON.parse(nextDataString);\n        const storyIdParam = data?.query?.profileParams?.[1];\n\n        if (storyIdParam && data?.props?.pageProps?.story) {\n            const story = data.props.pageProps.story.snapList.find((snap) => snap.snapId.value === storyIdParam);\n            if (story) {\n                if (story.snapMediaType === 0) {\n                    return {\n                        urls: story.snapUrls.mediaUrl,\n                        filename: `snapchat_${storyId}.jpg`,\n                        isPhoto: true\n                    }\n                }\n\n                return {\n                    urls: story.snapUrls.mediaUrl,\n                    filename: `snapchat_${storyId}.mp4`,\n                    audioFilename: `snapchat_${storyId}_audio`\n                }\n            }\n        }\n\n        const defaultStory = data?.props?.pageProps?.curatedHighlights?.[0];\n        if (defaultStory) {\n            return {\n                picker: defaultStory.snapList.map(snap => {\n                    const snapType = snap.snapMediaType === 0 ? \"photo\" : \"video\";\n                    const snapExt = snapType === \"video\" ? \"mp4\" : \"jpg\";\n                    let snapUrl = snap.snapUrls.mediaUrl;\n\n                    const proxy = createStream({\n                        service: \"snapchat\",\n                        type: \"proxy\",\n                        url: snapUrl,\n                        filename: `snapchat_${username}_${snap.timestampInSec.value}.${snapExt}`,\n                    });\n\n                    let thumbProxy;\n                    if (snapType === \"video\") thumbProxy = createStream({\n                        service: \"snapchat\",\n                        type: \"proxy\",\n                        url: snap.snapUrls.mediaPreviewUrl.value,\n                    });\n\n                    if (alwaysProxy) snapUrl = proxy;\n\n                    return {\n                        type: snapType,\n                        url: snapUrl,\n                        thumb: thumbProxy || proxy,\n                    }\n                })\n            }\n        }\n    }\n}\n\nexport default async function (obj) {\n    let params = obj;\n    if (obj.shortLink) {\n        params = await resolveRedirectingURL(`https://t.snapchat.com/${obj.shortLink}`);\n    }\n\n    if (params?.spotlightId) {\n        const result = await getSpotlight(params.spotlightId);\n        if (result) return result;\n    } else if (params?.username) {\n        const result = await getStory(params.username, params.storyId, obj.alwaysProxy);\n        if (result) return result;\n    }\n\n    return { error: \"fetch.fail\" };\n}\n"
  },
  {
    "path": "api/src/processing/services/soundcloud.js",
    "content": "import { env } from \"../../config.js\";\nimport { resolveRedirectingURL } from \"../url.js\";\n\nconst cachedID = {\n    version: '',\n    id: ''\n}\n\nasync function findClientID() {\n    try {\n        const sc = await fetch('https://soundcloud.com/').then(r => r.text()).catch(() => {});\n        const scVersion = String(sc.match(/<script>window\\.__sc_version=\"[0-9]{10}\"<\\/script>/)[0].match(/[0-9]{10}/));\n\n        if (cachedID.version === scVersion) {\n            return cachedID.id;\n        }\n\n        const scripts = sc.matchAll(/<script.+src=\"(.+)\">/g);\n\n        let clientid;\n        for (let script of scripts) {\n            const url = script[1];\n\n            if (!url?.startsWith('https://a-v2.sndcdn.com/')) {\n                return;\n            }\n\n            const scrf = await fetch(url).then(r => r.text()).catch(() => {});\n            const id = scrf.match(/\\(\"client_id=[A-Za-z0-9]{32}\"\\)/);\n\n            if (id && typeof id[0] === 'string') {\n                clientid = id[0].match(/[A-Za-z0-9]{32}/)[0];\n                break;\n            }\n        }\n        cachedID.version = scVersion;\n        cachedID.id = clientid;\n\n        return clientid;\n    } catch {}\n}\n\nconst findBestForPreset = (transcodings, preset) => {\n    let inferior;\n    for (const entry of transcodings) {\n        const protocol = entry?.format?.protocol;\n\n        if (entry.snipped || protocol?.includes('encrypted')) {\n            continue;\n        }\n\n        if (entry?.preset?.startsWith(`${preset}_`)) {\n            if (protocol === 'progressive') {\n                return entry;\n            }\n\n            inferior = entry;\n        }\n    }\n\n    return inferior;\n}\n\nexport default async function(obj) {\n    const clientId = await findClientID();\n    if (!clientId) return { error: \"fetch.fail\" };\n\n    let link;\n\n    if (obj.shortLink) {\n        obj = {\n            ...obj,\n            ...await resolveRedirectingURL(\n                `https://on.soundcloud.com/${obj.shortLink}`\n            )\n        }\n    }\n\n    if (obj.author && obj.song) {\n        link = `https://soundcloud.com/${obj.author}/${obj.song}`;\n        if (obj.accessKey) {\n            link += `/s-${obj.accessKey}`;\n        }\n    }\n\n    if (!link && obj.shortLink) return { error: \"fetch.short_link\" };\n    if (!link) return { error: \"link.unsupported\" };\n\n    const resolveURL = new URL(\"https://api-v2.soundcloud.com/resolve\");\n    resolveURL.searchParams.set(\"url\", link);\n    resolveURL.searchParams.set(\"client_id\", clientId);\n\n    const json = await fetch(resolveURL).then(r => r.json()).catch(() => {});\n    if (!json) return { error: \"fetch.fail\" };\n\n    if (json.duration > env.durationLimit * 1000) {\n        return { error: \"content.too_long\" };\n    }\n\n    if (json.policy === \"BLOCK\") {\n        return { error: \"content.region\" };\n    }\n\n    if (json.policy === \"SNIP\") {\n        return { error: \"content.paid\" };\n    }\n\n    if (!json.media?.transcodings || !json.media?.transcodings.length === 0) {\n        return { error: \"fetch.empty\" };\n    }\n\n    let bestAudio = \"opus\",\n        selectedStream = findBestForPreset(json.media.transcodings, \"opus\");\n\n    const mp3Media = findBestForPreset(json.media.transcodings, \"mp3\");\n\n    // use mp3 if present if user prefers it or if opus isn't available\n    if (mp3Media && (obj.format === \"mp3\" || !selectedStream)) {\n        selectedStream = mp3Media;\n        bestAudio = \"mp3\"\n    }\n\n    if (!selectedStream) {\n        return { error: \"fetch.empty\" };\n    }\n\n    const fileUrl = new URL(selectedStream.url);\n    fileUrl.searchParams.set(\"client_id\", clientId);\n    fileUrl.searchParams.set(\"track_authorization\", json.track_authorization);\n\n    const file = await fetch(fileUrl)\n                     .then(async r => new URL((await r.json()).url))\n                     .catch(() => {});\n\n    if (!file) return { error: \"fetch.empty\" };\n\n    const artist = json.user?.username?.trim();\n    const fileMetadata = {\n        title: json.title?.trim(),\n        album: json.publisher_metadata?.album_title?.trim(),\n        artist,\n        album_artist: artist,\n        composer: json.publisher_metadata?.writer_composer?.trim(),\n        genre: json.genre?.trim(),\n        date: json.display_date?.trim().slice(0, 10),\n        copyright: json.license?.trim(),\n    }\n\n    let cover;\n    if (json.artwork_url) {\n        const coverUrl = json.artwork_url.replace(/-large/, \"-t1080x1080\");\n        const testCover = await fetch(coverUrl)\n            .then(r => r.status === 200)\n            .catch(() => {});\n\n        if (testCover) {\n            cover = coverUrl;\n        }\n    }\n\n    return {\n        urls: file.toString(),\n        cover,\n        filenameAttributes: {\n            service: \"soundcloud\",\n            id: json.id,\n            ...fileMetadata\n        },\n        bestAudio,\n        fileMetadata,\n        isHLS: file.pathname.endsWith('.m3u8'),\n    }\n}\n"
  },
  {
    "path": "api/src/processing/services/streamable.js",
    "content": "export default async function(obj) {\n    let video = await fetch(`https://api.streamable.com/videos/${obj.id}`)\n                      .then(r => r.status === 200 ? r.json() : false)\n                      .catch(() => {});\n\n    if (!video) return { error: \"fetch.empty\" };\n\n    let best = video.files[\"mp4-mobile\"];\n    if (video.files.mp4 && (obj.isAudioOnly || obj.quality === \"max\" || obj.quality >= 720)) {\n        best = video.files.mp4;\n    }\n\n    if (best) return {\n        urls: best.url,\n        filename: `streamable_${obj.id}_${best.width}x${best.height}.mp4`,\n        audioFilename: `streamable_${obj.id}_audio`,\n        fileMetadata: {\n            title: video.title\n        }\n    }\n    return { error: \"fetch.fail\" }\n}\n"
  },
  {
    "path": "api/src/processing/services/tiktok.js",
    "content": "import Cookie from \"../cookie/cookie.js\";\n\nimport { extract, normalizeURL } from \"../url.js\";\nimport { genericUserAgent } from \"../../config.js\";\nimport { updateCookie } from \"../cookie/manager.js\";\nimport { createStream } from \"../../stream/manage.js\";\nimport { convertLanguageCode } from \"../../misc/language-codes.js\";\n\nconst shortDomain = \"https://vt.tiktok.com/\";\n\nexport default async function(obj) {\n    const cookie = new Cookie({});\n    let postId = obj.postId;\n\n    if (!postId) {\n        let html = await fetch(`${shortDomain}${obj.shortLink}`, {\n            redirect: \"manual\",\n            headers: {\n                \"user-agent\": genericUserAgent.split(' Chrome/1')[0]\n            }\n        }).then(r => r.text()).catch(() => {});\n\n        if (!html) return { error: \"fetch.fail\" };\n\n        if (html.startsWith('<a href=\"https://')) {\n            const extractedURL = html.split('<a href=\"')[1].split('?')[0];\n            const { host, patternMatch } = extract(normalizeURL(extractedURL));\n            if (host === \"tiktok\") {\n                postId = patternMatch?.postId;\n            }\n        }\n    }\n    if (!postId) return { error: \"fetch.short_link\" };\n\n    // should always be /video/, even for photos\n    const res = await fetch(`https://www.tiktok.com/@i/video/${postId}`, {\n        headers: {\n            \"user-agent\": genericUserAgent,\n            cookie,\n        }\n    })\n    updateCookie(cookie, res.headers);\n\n    const html = await res.text();\n\n    let detail;\n    try {\n        const json = html\n            .split('<script id=\"__UNIVERSAL_DATA_FOR_REHYDRATION__\" type=\"application/json\">')[1]\n            .split('</script>')[0];\n\n        const data = JSON.parse(json);\n        const videoDetail = data[\"__DEFAULT_SCOPE__\"][\"webapp.video-detail\"];\n\n        if (!videoDetail) throw \"no video detail found\";\n\n        // status_deleted or etc\n        if (videoDetail.statusMsg) {\n            return { error: \"content.post.unavailable\"};\n        }\n\n        detail = videoDetail?.itemInfo?.itemStruct;\n    } catch {\n        return { error: \"fetch.fail\" };\n    }\n\n    if (detail.isContentClassified) {\n        return { error: \"content.post.age\" };\n    }\n\n    if (!detail.author) {\n        return { error: \"fetch.empty\" };\n    }\n\n    let video, videoFilename, audioFilename, audio, images,\n        filenameBase = `tiktok_${detail.author?.uniqueId}_${postId}`,\n        bestAudio; // will get defaulted to m4a later on in match-action\n\n    images = detail.imagePost?.images;\n\n    let playAddr = detail.video?.playAddr;\n\n    if (obj.h265) {\n        const h265PlayAddr = detail?.video?.bitrateInfo?.find(b => b.CodecType.includes(\"h265\"))?.PlayAddr.UrlList[0]\n        playAddr = h265PlayAddr || playAddr\n    }\n\n    if (!obj.isAudioOnly && !images) {\n        video = playAddr;\n        videoFilename = `${filenameBase}.mp4`;\n    } else {\n        audio = playAddr;\n        audioFilename = `${filenameBase}_audio`;\n\n        if (obj.fullAudio || !audio) {\n            audio = detail.music.playUrl;\n            audioFilename += `_original`\n        }\n        if (audio.includes(\"mime_type=audio_mpeg\")) bestAudio = 'mp3';\n    }\n\n    if (video) {\n        let subtitles, fileMetadata;\n        if (obj.subtitleLang && detail?.video?.subtitleInfos?.length) {\n            const langCode = convertLanguageCode(obj.subtitleLang);\n            const subtitle = detail?.video?.subtitleInfos.find(\n                s => s.LanguageCodeName.startsWith(langCode) && s.Format === \"webvtt\"\n            )\n            if (subtitle) {\n                subtitles = subtitle.Url;\n                fileMetadata = {\n                    sublanguage: langCode,\n                }\n            }\n        }\n        return {\n            urls: video,\n            subtitles,\n            fileMetadata,\n            filename: videoFilename,\n            headers: { cookie }\n        }\n    }\n\n    if (images && obj.isAudioOnly) {\n        return {\n            urls: audio,\n            audioFilename: audioFilename,\n            isAudioOnly: true,\n            bestAudio,\n            headers: { cookie }\n        }\n    }\n\n    if (images) {\n        let imageLinks = images\n            .map(i => i.imageURL.urlList.find(p => p.includes(\".jpeg?\")))\n            .map((url, i) => {\n                if (obj.alwaysProxy) url = createStream({\n                    service: \"tiktok\",\n                    type: \"proxy\",\n                    url,\n                    filename: `${filenameBase}_photo_${i + 1}.jpg`\n                })\n\n                return {\n                    type: \"photo\",\n                    url\n                }\n            });\n\n        return {\n            picker: imageLinks,\n            urls: audio,\n            audioFilename: audioFilename,\n            isAudioOnly: true,\n            bestAudio,\n            headers: { cookie }\n        }\n    }\n\n    if (audio) {\n        return {\n            urls: audio,\n            audioFilename: audioFilename,\n            isAudioOnly: true,\n            bestAudio,\n            headers: { cookie }\n        }\n    }\n\n    return { error: \"fetch.empty\" };\n}\n"
  },
  {
    "path": "api/src/processing/services/tumblr.js",
    "content": "import psl from \"@imput/psl\";\n\nconst API_KEY = 'jrsCWX1XDuVxAFO4GkK147syAoN8BJZ5voz8tS80bPcj26Vc5Z';\nconst API_BASE = 'https://api-http2.tumblr.com';\n\nfunction request(domain, id) {\n    const url = new URL(`/v2/blog/${domain}/posts/${id}/permalink`, API_BASE);\n    url.searchParams.set('api_key', API_KEY);\n    url.searchParams.set('fields[blogs]', 'uuid,name,avatar,?description,?can_message,?can_be_followed,?is_adult,?reply_conditions,'\n                                            + '?theme,?title,?url,?is_blocked_from_primary,?placement_id,?primary,?updated,?followed,'\n                                            + '?ask,?can_subscribe,?paywall_access,?subscription_plan,?is_blogless_advertiser,?tumblrmart_accessories');\n\n    return fetch(url, {\n        headers: {\n            'User-Agent': 'Tumblr/iPhone/33.3/333010/17.3.1/tumblr',\n            'X-Version': 'iPhone/33.3/333010/17.3.1/tumblr'\n        }\n    }).then(a => a.json()).catch(() => {});\n}\n\nexport default async function(input) {\n    let { subdomain } = psl.parse(input.url.hostname);\n\n    if (subdomain?.includes('.')) {\n        return { error: \"link.unsupported\" };\n    } else if (subdomain === 'www' || subdomain === 'at') {\n        subdomain = undefined\n    }\n\n    const domain = `${subdomain ?? input.user}.tumblr.com`;\n    const data = await request(domain, input.id);\n\n    const element = data?.response?.timeline?.elements?.[0];\n    if (!element) return { error: \"fetch.empty\" };\n\n    const contents = [\n        ...element.content,\n        ...element?.trail?.map(t => t.content).flat()\n    ]\n\n    const audio = contents.find(c => c.type === 'audio');\n    if (audio && audio.provider === 'tumblr') {\n        const fileMetadata = {\n            title: audio?.title,\n            artist: audio?.artist\n        };\n\n        return {\n            urls: audio.media.url,\n            filenameAttributes: {\n                service: 'tumblr',\n                id: input.id,\n                title: fileMetadata.title,\n                author: fileMetadata.artist\n            },\n            isAudioOnly: true,\n            bestAudio: \"mp3\",\n        }\n    }\n\n    const video = contents.find(c => c.type === 'video');\n    if (video && video.provider === 'tumblr') {\n        return {\n            urls: video.media.url,\n            filename: `tumblr_${input.id}.mp4`,\n            audioFilename: `tumblr_${input.id}_audio`\n        }\n    }\n\n    return { error: \"link.unsupported\" }\n}\n"
  },
  {
    "path": "api/src/processing/services/twitch.js",
    "content": "import { env } from \"../../config.js\";\n\nconst gqlURL = \"https://gql.twitch.tv/gql\";\nconst clientIdHead = { \"client-id\": \"kimne78kx3ncx6brgo4mv6wki5h1ko\" };\n\nexport default async function (obj) {\n    const req_metadata = await fetch(gqlURL, {\n        method: \"POST\",\n        headers: clientIdHead,\n        body: JSON.stringify({\n            query: `{\n            clip(slug: \"${obj.clipId}\") {\n                broadcaster {\n                    login\n                }\n                createdAt\n                curator {\n                    login\n                }\n                durationSeconds\n                id\n                medium: thumbnailURL(width: 480, height: 272)\n                title\n                videoQualities {\n                    quality\n                    sourceURL\n                }\n            }\n        }`\n        })\n    }).then(r => r.status === 200 ? r.json() : false).catch(() => {});\n\n    if (!req_metadata) return { error: \"fetch.fail\" };\n\n    const clipMetadata = req_metadata.data.clip;\n\n    if (clipMetadata.durationSeconds > env.durationLimit) {\n        return { error: \"content.too_long\" };\n    }\n    if (!clipMetadata.videoQualities || !clipMetadata.broadcaster) {\n        return { error: \"fetch.empty\" };\n    }\n\n    const req_token = await fetch(gqlURL, {\n        method: \"POST\",\n        headers: clientIdHead,\n        body: JSON.stringify([\n            {\n                \"operationName\": \"VideoAccessToken_Clip\",\n                \"variables\": {\n                    \"slug\": obj.clipId\n                },\n                \"extensions\": {\n                    \"persistedQuery\": {\n                        \"version\": 1,\n                        \"sha256Hash\": \"36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11\"\n                    }\n                }\n            }\n        ])\n    }).then(r => r.status === 200 ? r.json() : false).catch(() => {});\n\n    if (!req_token) return { error: \"fetch.fail\" };\n\n    const formats = clipMetadata.videoQualities;\n    const format = formats.find(f => f.quality === obj.quality) || formats[0];\n\n    return {\n        type: \"proxy\",\n        urls: `${format.sourceURL}?${new URLSearchParams({\n            sig: req_token[0].data.clip.playbackAccessToken.signature,\n            token: req_token[0].data.clip.playbackAccessToken.value\n        })}`,\n        fileMetadata: {\n            title: clipMetadata.title.trim(),\n            artist: `Twitch Clip by @${clipMetadata.broadcaster.login}, clipped by @${clipMetadata.curator.login}`,\n        },\n        filenameAttributes: {\n            service: \"twitch\",\n            id: clipMetadata.id,\n            title: clipMetadata.title.trim(),\n            author: `${clipMetadata.broadcaster.login}, clipped by ${clipMetadata.curator.login}`,\n            qualityLabel: `${format.quality}p`,\n            extension: 'mp4'\n        },\n        filename: `twitchclip_${clipMetadata.id}_${format.quality}p.mp4`,\n        audioFilename: `twitchclip_${clipMetadata.id}_audio`\n    }\n}\n"
  },
  {
    "path": "api/src/processing/services/twitter.js",
    "content": "import HLS from \"hls-parser\";\nimport { genericUserAgent } from \"../../config.js\";\nimport { createStream } from \"../../stream/manage.js\";\nimport { getCookie, updateCookie } from \"../cookie/manager.js\";\n\nconst graphqlURL = 'https://api.x.com/graphql/4Siu98E55GquhG52zHdY5w/TweetDetail';\nconst tokenURL = 'https://api.x.com/1.1/guest/activate.json';\n\nconst tweetFeatures = JSON.stringify({\"rweb_video_screen_enabled\":false,\"payments_enabled\":false,\"rweb_xchat_enabled\":false,\"profile_label_improvements_pcf_label_in_post_enabled\":true,\"rweb_tipjar_consumption_enabled\":true,\"verified_phone_label_enabled\":false,\"creator_subscriptions_tweet_preview_api_enabled\":true,\"responsive_web_graphql_timeline_navigation_enabled\":true,\"responsive_web_graphql_skip_user_profile_image_extensions_enabled\":false,\"premium_content_api_read_enabled\":false,\"communities_web_enable_tweet_community_results_fetch\":true,\"c9s_tweet_anatomy_moderator_badge_enabled\":true,\"responsive_web_grok_analyze_button_fetch_trends_enabled\":false,\"responsive_web_grok_analyze_post_followups_enabled\":true,\"responsive_web_jetfuel_frame\":true,\"responsive_web_grok_share_attachment_enabled\":true,\"articles_preview_enabled\":true,\"responsive_web_edit_tweet_api_enabled\":true,\"graphql_is_translatable_rweb_tweet_is_translatable_enabled\":true,\"view_counts_everywhere_api_enabled\":true,\"longform_notetweets_consumption_enabled\":true,\"responsive_web_twitter_article_tweet_consumption_enabled\":true,\"tweet_awards_web_tipping_enabled\":false,\"responsive_web_grok_show_grok_translated_post\":false,\"responsive_web_grok_analysis_button_from_backend\":true,\"creator_subscriptions_quote_tweet_preview_enabled\":false,\"freedom_of_speech_not_reach_fetch_enabled\":true,\"standardized_nudges_misinfo\":true,\"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled\":true,\"longform_notetweets_rich_text_read_enabled\":true,\"longform_notetweets_inline_media_enabled\":true,\"responsive_web_grok_image_annotation_enabled\":true,\"responsive_web_grok_imagine_annotation_enabled\":true,\"responsive_web_grok_community_note_auto_translation_is_enabled\":false,\"responsive_web_enhance_cards_enabled\":false});\n\nconst tweetFieldToggles = JSON.stringify({\"withArticleRichContentState\":true,\"withArticlePlainText\":false,\"withGrokAnalyze\":false,\"withDisallowedReplyControls\":false});\n\nconst commonHeaders = {\n    \"user-agent\": genericUserAgent,\n    \"authorization\": \"Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA\",\n    \"x-twitter-client-language\": \"en\",\n    \"x-twitter-active-user\": \"yes\",\n    \"accept-language\": \"en\"\n}\n\n// fix all videos affected by the container bug in twitter muxer (took them over two weeks to fix it????)\nconst TWITTER_EPOCH = 1288834974657n;\nconst badContainerStart = new Date(1701446400000);\nconst badContainerEnd = new Date(1702605600000);\n\nfunction needsFixing(media) {\n    const representativeId = media.source_status_id_str ?? media.id_str;\n\n    // syndication api doesn't have media ids in its response,\n    // so we just assume it's all good\n    if (!representativeId) return false;\n\n    const mediaTimestamp = new Date(\n        Number((BigInt(representativeId) >> 22n) + TWITTER_EPOCH)\n    );\n    return mediaTimestamp > badContainerStart && mediaTimestamp < badContainerEnd\n}\n\nfunction bestQuality(arr) {\n    return arr.filter(v => v.content_type === \"video/mp4\")\n        .reduce((a, b) => Number(a?.bitrate) > Number(b?.bitrate) ? a : b)\n        .url\n}\n\nlet _cachedToken;\nconst getGuestToken = async (dispatcher, forceReload = false) => {\n    if (_cachedToken && !forceReload) {\n        return _cachedToken;\n    }\n\n    const tokenResponse = await fetch(tokenURL, {\n        method: 'POST',\n        headers: commonHeaders,\n        dispatcher\n    }).then(r => r.status === 200 && r.json()).catch(() => {})\n\n    if (tokenResponse?.guest_token) {\n        return _cachedToken = tokenResponse.guest_token\n    }\n}\n\nconst requestSyndication = async(dispatcher, tweetId) => {\n    // thank you\n    // https://github.com/yt-dlp/yt-dlp/blob/05c8023a27dd37c49163c0498bf98e3e3c1cb4b9/yt_dlp/extractor/twitter.py#L1334\n    const token = (id) => ((Number(id) / 1e15) * Math.PI).toString(36).replace(/(0+|\\.)/g, '');\n    const syndicationUrl = new URL(\"https://cdn.syndication.twimg.com/tweet-result\");\n\n    syndicationUrl.searchParams.set(\"id\", tweetId);\n    syndicationUrl.searchParams.set(\"token\", token(tweetId));\n\n    const result = await fetch(syndicationUrl, {\n        headers: {\n            \"user-agent\": genericUserAgent\n        },\n        dispatcher\n    });\n\n    return result;\n}\n\nconst requestTweet = async(dispatcher, tweetId, token, cookie) => {\n    const graphqlTweetURL = new URL(graphqlURL);\n\n    let headers = {\n        ...commonHeaders,\n        'content-type': 'application/json',\n        'x-guest-token': token,\n        cookie: `guest_id=${encodeURIComponent(`v1:${token}`)}`\n    }\n\n    if (cookie) {\n        headers = {\n            ...commonHeaders,\n            'content-type': 'application/json',\n            'X-Twitter-Auth-Type': 'OAuth2Session',\n            'x-csrf-token': cookie.values().ct0,\n            cookie\n        }\n    }\n\n    graphqlTweetURL.searchParams.set('variables',\n        JSON.stringify({\n            focalTweetId: tweetId,\n            with_rux_injections: false,\n            rankingMode: \"Relevance\",\n            includePromotedContent: true,\n            withCommunity: true,\n            withQuickPromoteEligibilityTweetFields: true,\n            withBirdwatchNotes: true,\n            withVoice: true\n        })\n    );\n    graphqlTweetURL.searchParams.set('features', tweetFeatures);\n    graphqlTweetURL.searchParams.set('fieldToggles', tweetFieldToggles);\n\n    let result = await fetch(graphqlTweetURL, { headers, dispatcher });\n    updateCookie(cookie, result.headers);\n\n    // we might have been missing the ct0 cookie, retry\n    if (result.status === 403 && result.headers.get('set-cookie')) {\n        const cookieValues = cookie?.values();\n        if (cookieValues?.ct0) {\n            result = await fetch(graphqlTweetURL, {\n                headers: {\n                    ...headers,\n                    'x-csrf-token': cookieValues.ct0\n                },\n                dispatcher\n            });\n        }\n    }\n\n    return result\n}\n\nconst parseCard = (cardOuter) => {\n    const card = JSON.parse(\n      (cardOuter?.legacy?.binding_values[0].value\n       || cardOuter?.binding_values?.unified_card)?.string_value,\n    );\n\n    if (![\"video_website\", \"image_website\"].includes(card?.type)\n        || !card?.media_entities\n        || card?.component_objects?.media_1?.type !== \"media\") {\n      return;\n    }\n\n    const mediaId = card.component_objects?.media_1?.data?.id;\n    return [card.media_entities[mediaId]];\n};\n\nconst extractGraphqlMedia = async (thread, dispatcher, id, guestToken, cookie) => {\n    const addInsn = thread?.data?.threaded_conversation_with_injections_v2?.instructions?.find(\n        insn => insn.type === 'TimelineAddEntries'\n    );\n\n    const tweetResult = addInsn?.entries?.find(\n        entry => entry.entryId === `tweet-${id}`\n    )?.content?.itemContent?.tweet_results?.result;\n\n    let tweetTypename = tweetResult?.__typename;\n\n    if (!tweetTypename) {\n        return { error: \"fetch.empty\" }\n    }\n\n    if (tweetTypename === \"TweetUnavailable\" || tweetTypename === \"TweetTombstone\") {\n        const reason = tweetResult?.result?.reason;\n        if (reason === 'Protected') {\n            return { error: \"content.post.private\" };\n        } else if (reason === \"NsfwLoggedOut\" || tweetResult?.tombstone?.text?.text?.startsWith('Age-restricted')) {\n            if (!cookie) {\n                return { error: \"content.post.age\" };\n            }\n\n            const tweet = await requestTweet(dispatcher, id, guestToken, cookie).then(t => t.json());\n            return extractGraphqlMedia(tweet, dispatcher, id, guestToken);\n        }\n    }\n\n    if (![\"Tweet\", \"TweetWithVisibilityResults\"].includes(tweetTypename)) {\n        return { error: \"content.post.unavailable\" }\n    }\n\n    let baseTweet = tweetResult.legacy,\n        repostedTweet = baseTweet?.retweeted_status_result?.result.legacy.extended_entities;\n\n    if (tweetTypename === \"TweetWithVisibilityResults\") {\n        baseTweet = tweetResult.tweet.legacy;\n        repostedTweet = baseTweet?.retweeted_status_result?.result.tweet.legacy.extended_entities;\n    }\n\n    if (tweetResult.card?.legacy?.binding_values?.length) {\n        return parseCard(tweetResult.card);\n    }\n\n    return (repostedTweet?.media || baseTweet?.extended_entities?.media);\n}\n\nexport default async function({ id, index, toGif, dispatcher, alwaysProxy, subtitleLang }) {\n    const cookie = await getCookie('twitter');\n\n    let guestToken = await getGuestToken(dispatcher);\n    if (!guestToken) return { error: \"fetch.fail\" };\n\n    let tweet = await requestTweet(dispatcher, id, guestToken);\n\n    if ([403, 404, 429].includes(tweet.status)) {\n        // get new token & retry if old one expired\n        if ([403, 429].includes(tweet.status)) {\n            guestToken = await getGuestToken(dispatcher, true);\n        }\n        tweet = await requestTweet(dispatcher, id, guestToken, cookie);\n    }\n\n    let media;\n    try {\n        tweet = await tweet.json();\n        media = await extractGraphqlMedia(tweet, dispatcher, id, guestToken, cookie);\n    } catch {}\n\n    // if graphql requests fail, then resort to tweet embed api\n    if (!media || 'error' in media) {\n        try {\n            tweet = await requestSyndication(dispatcher, id);\n            tweet = await tweet.json();\n\n            if (tweet?.card) {\n                media = parseCard(tweet.card);\n            }\n        } catch {}\n\n        media = tweet?.mediaDetails ?? media;\n    }\n\n    if (!media || 'error' in media) {\n        return { error: media?.error || \"fetch.empty\" };\n    }\n\n    // check if there's a video at given index (/video/<index>)\n    if (index >= 0 && index < media?.length) {\n        media = [media[index]]\n    }\n\n    const getFileExt = (url) => new URL(url).pathname.split(\".\", 2)[1];\n\n    const proxyMedia = (url, filename) => createStream({\n        service: \"twitter\",\n        type: \"proxy\",\n        url, filename,\n    });\n\n    const extractSubtitles = async (hlsUrl) => {\n        const mainHls = await fetch(hlsUrl).then(r => r.text()).catch(() => {});\n        if (!mainHls) return;\n\n        const subtitle = HLS.parse(mainHls)?.variants[0]?.subtitles?.find(\n            s => s.language.startsWith(subtitleLang)\n        );\n        if (!subtitle) return;\n\n        const subtitleUrl = new URL(subtitle.uri, hlsUrl).toString();\n        const subtitleHls = await fetch(subtitleUrl).then(r => r.text());\n        if (!subtitleHls) return;\n\n        const finalSubtitlePath = HLS.parse(subtitleHls)?.segments?.[0].uri;\n        if (!finalSubtitlePath) return;\n\n        const finalSubtitleUrl = new URL(finalSubtitlePath, hlsUrl).toString();\n\n        return {\n            url: finalSubtitleUrl,\n            language: subtitle.language,\n        };\n    }\n\n    switch (media?.length) {\n        case undefined:\n        case 0:\n            return {\n                error: \"fetch.empty\"\n            }\n        case 1:\n            const mediaItem = media[0];\n            if (mediaItem.type === \"photo\") {\n                return {\n                    type: \"proxy\",\n                    isPhoto: true,\n                    filename: `twitter_${id}.${getFileExt(mediaItem.media_url_https)}`,\n                    urls: `${mediaItem.media_url_https}?name=4096x4096`\n                }\n            }\n\n            let subtitles;\n            let fileMetadata;\n            if (mediaItem.type === \"video\" && subtitleLang) {\n                const hlsVariant = mediaItem.video_info?.variants?.find(\n                    v => v.content_type === \"application/x-mpegURL\"\n                );\n                if (hlsVariant) {\n                    const { url, language } = await extractSubtitles(hlsVariant.url) || {};\n                    subtitles = url;\n                    if (language) fileMetadata = { sublanguage: language };\n                }\n            }\n\n            return {\n                type: subtitles || needsFixing(mediaItem) ? \"remux\" : \"proxy\",\n                urls: bestQuality(mediaItem.video_info.variants),\n                filename: `twitter_${id}.mp4`,\n                audioFilename: `twitter_${id}_audio`,\n                isGif: mediaItem.type === \"animated_gif\",\n                subtitles,\n                fileMetadata,\n            }\n        default:\n            const proxyThumb = (url, i) =>\n                proxyMedia(url, `twitter_${id}_${i + 1}.${getFileExt(url)}`);\n\n            const picker = media.map((content, i) => {\n                if (content.type === \"photo\") {\n                    let url = `${content.media_url_https}?name=4096x4096`;\n                    let proxiedImage = proxyThumb(url, i);\n\n                    if (alwaysProxy) url = proxiedImage;\n\n                    return {\n                        type: \"photo\",\n                        url,\n                        thumb: proxiedImage,\n                    }\n                }\n\n                let url = bestQuality(content.video_info.variants);\n                const shouldRenderGif = content.type === \"animated_gif\" && toGif;\n                const videoFilename = `twitter_${id}_${i + 1}.${shouldRenderGif ? \"gif\" : \"mp4\"}`;\n\n                let type = \"video\";\n                if (shouldRenderGif) type = \"gif\";\n\n                if (needsFixing(content) || shouldRenderGif) {\n                    url = createStream({\n                        service: \"twitter\",\n                        type: shouldRenderGif ? \"gif\" : \"remux\",\n                        url,\n                        filename: videoFilename,\n                    })\n                } else if (alwaysProxy) {\n                    url = proxyMedia(url, videoFilename);\n                }\n\n                return {\n                    type,\n                    url,\n                    thumb: proxyThumb(content.media_url_https, i),\n                }\n            });\n            return { picker };\n    }\n}\n"
  },
  {
    "path": "api/src/processing/services/vimeo.js",
    "content": "import HLS from \"hls-parser\";\nimport { env } from \"../../config.js\";\nimport { merge } from '../../misc/utils.js';\nimport { getCookie } from \"../cookie/manager.js\";\n\nconst resolutionMatch = {\n    \"3840\": 2160,\n    \"2732\": 1440,\n    \"2560\": 1440,\n    \"2048\": 1080,\n    \"1920\": 1080,\n    \"1366\": 720,\n    \"1280\": 720,\n    \"960\": 480,\n    \"640\": 360,\n    \"426\": 240\n}\n\nconst genericHeaders = {\n    Accept: 'application/vnd.vimeo.*+json; version=3.4.10',\n    'User-Agent': 'com.vimeo.android.videoapp (Google, Pixel 7a, google, Android 16/36 Version 11.8.1) Kotlin VimeoNetworking/3.12.0',\n    Authorization: 'Basic NzRmYTg5YjgxMWExY2JiNzUwZDg1MjhkMTYzZjQ4YWYyOGEyZGJlMTp4OGx2NFd3QnNvY1lkamI2UVZsdjdDYlNwSDUrdm50YzdNNThvWDcwN1JrenJGZC9tR1lReUNlRjRSVklZeWhYZVpRS0tBcU9YYzRoTGY2Z1dlVkJFYkdJc0dMRHpoZWFZbU0reDRqZ1dkZ1diZmdIdGUrNUM5RVBySlM0VG1qcw==',\n    'Accept-Language': 'en',\n}\n\nlet bearer = '';\n\nconst getBearer = async (refresh = false) => {\n    const cookie = getCookie('vimeo_bearer')?.values?.()?.access_token;\n    if ((bearer || cookie) && !refresh) return bearer || cookie;\n\n    const oauthResponse = await fetch(\n        'https://api.vimeo.com/oauth/authorize/client',\n        {\n            method: 'POST',\n            body: new URLSearchParams({\n                scope: 'private public create edit delete interact upload purchased stats',\n                grant_type: 'client_credentials',\n            }).toString(),\n            headers: {\n                ...genericHeaders,\n                'Content-Type': 'application/x-www-form-urlencoded',\n            }\n        }\n    )\n    .then(a => a.json())\n    .catch(() => {});\n\n    if (!oauthResponse || !oauthResponse.access_token) {\n        return;\n    }\n\n    return bearer = oauthResponse.access_token;\n}\n\nconst requestApiInfo = (bearerToken, videoId, password) => {\n    if (password) {\n        videoId += `:${password}`\n    }\n\n    return fetch(\n        `https://api.vimeo.com/videos/${videoId}`,\n        {\n            headers: {\n                ...genericHeaders,\n                Authorization: `Bearer ${bearerToken}`,\n            }\n        }\n    )\n    .then(a => a.json())\n    .catch(() => {});\n}\n\nconst compareQuality = (rendition, requestedQuality) => {\n    const quality = parseInt(rendition);\n    return Math.abs(quality - requestedQuality);\n}\n\nconst getDirectLink = async (data, quality, subtitleLang) => {\n    if (!data.files) return;\n\n    const match = data.files\n        .filter(f => f.rendition?.endsWith('p'))\n        .reduce((prev, next) => {\n            const delta = {\n                prev: compareQuality(prev.rendition, quality),\n                next: compareQuality(next.rendition, quality)\n            };\n\n            return delta.prev < delta.next ? prev : next;\n        });\n\n    if (!match) return;\n\n    let subtitles;\n    if (subtitleLang && data.config_url) {\n        const config = await fetch(data.config_url)\n                    .then(r => r.json())\n                    .catch(() => {});\n\n        if (config && config.request?.text_tracks?.length) {\n            subtitles = config.request.text_tracks.find(\n                t => t.lang.startsWith(subtitleLang)\n            );\n            subtitles = new URL(subtitles.url, \"https://player.vimeo.com/\").toString();\n        }\n    }\n\n    return {\n        urls: match.link,\n        subtitles,\n        filenameAttributes: {\n            resolution: `${match.width}x${match.height}`,\n            qualityLabel: match.rendition,\n            extension: \"mp4\"\n        },\n        bestAudio: \"mp3\",\n    }\n}\n\nconst getHLS = async (configURL, obj) => {\n    if (!configURL) return;\n\n    const api = await fetch(configURL)\n                    .then(r => r.json())\n                    .catch(() => {});\n    if (!api) return { error: \"fetch.fail\" };\n\n    if (api.video?.duration > env.durationLimit) {\n        return { error: \"content.too_long\" };\n    }\n\n    const urlMasterHLS = api.request?.files?.hls?.cdns?.akfire_interconnect_quic?.url;\n    if (!urlMasterHLS) return { error: \"fetch.fail\" };\n\n    const masterHLS = await fetch(urlMasterHLS)\n                            .then(r => r.text())\n                            .catch(() => {});\n\n    if (!masterHLS) return { error: \"fetch.fail\" };\n\n    const variants = HLS.parse(masterHLS)?.variants?.sort(\n        (a, b) => Number(b.bandwidth) - Number(a.bandwidth)\n    );\n    if (!variants || variants.length === 0) return { error: \"fetch.empty\" };\n\n    let bestQuality;\n\n    if (obj.quality < resolutionMatch[variants[0]?.resolution?.width]) {\n        bestQuality = variants.find(v =>\n            (obj.quality === resolutionMatch[v.resolution.width])\n        );\n    }\n\n    if (!bestQuality) bestQuality = variants[0];\n\n    const expandLink = (path) => {\n        return new URL(path, urlMasterHLS).toString();\n    };\n\n    let urls = expandLink(bestQuality.uri);\n\n    const audioPath = bestQuality?.audio[0]?.uri;\n    if (audioPath) {\n        urls = [\n            urls,\n            expandLink(audioPath)\n        ]\n    } else if (obj.isAudioOnly) {\n        return { error: \"fetch.empty\" };\n    }\n\n    return {\n        urls,\n        isHLS: true,\n        filenameAttributes: {\n            resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,\n            qualityLabel: `${resolutionMatch[bestQuality.resolution.width]}p`,\n            extension: \"mp4\"\n        },\n        bestAudio: \"mp3\",\n    }\n}\n\nexport default async function(obj) {\n    let quality = obj.quality === \"max\" ? 9000 : Number(obj.quality);\n    if (quality < 240) quality = 240;\n    if (!quality || obj.isAudioOnly) quality = 9000;\n\n    const bearerToken = await getBearer();\n    if (!bearerToken) {\n        return { error: \"fetch.fail\" };\n    }\n\n    let info = await requestApiInfo(bearerToken, obj.id, obj.password);\n    let response;\n\n    // auth error, try to refresh the token\n    if (info?.error_code === 8003) {\n        const newBearer = await getBearer(true);\n        if (!newBearer) {\n            return { error: \"fetch.fail\" };\n        }\n        info = await requestApiInfo(newBearer, obj.id, obj.password);\n    }\n\n    // if there's still no info, then return a generic error\n    if (!info || info.error_code) {\n        return { error: \"fetch.empty\" };\n    }\n\n    if (obj.isAudioOnly) {\n        response = await getHLS(info.config_url, { ...obj, quality });\n    }\n\n    if (!response) response = await getDirectLink(info, quality, obj.subtitleLang);\n    if (!response) response = { error: \"fetch.empty\" };\n\n    if (response.error) {\n        return response;\n    }\n\n    const fileMetadata = {\n        title: info.name,\n        artist: info.user.name,\n    };\n\n    if (response.subtitles) {\n        fileMetadata.sublanguage = obj.subtitleLang;\n    }\n\n    return merge(\n        {\n            fileMetadata,\n            filenameAttributes: {\n                service: \"vimeo\",\n                id: obj.id,\n                title: fileMetadata.title,\n                author: fileMetadata.artist,\n            }\n        },\n        response\n    );\n}\n"
  },
  {
    "path": "api/src/processing/services/vk.js",
    "content": "import { env } from \"../../config.js\";\n\nconst resolutions = [\"2160\", \"1440\", \"1080\", \"720\", \"480\", \"360\", \"240\", \"144\"];\n\nconst oauthUrl = \"https://oauth.vk.com/oauth/get_anonym_token\";\nconst apiUrl = \"https://api.vk.com/method\";\n\nconst clientId = \"51552953\";\nconst clientSecret = \"qgr0yWwXCrsxA1jnRtRX\";\n\n// used in stream/shared.js for accessing media files\nexport const vkClientAgent = \"com.vk.vkvideo.prod/822 (iPhone, iOS 16.7.7, iPhone10,4, Scale/2.0) SAK/1.119\";\n\nconst cachedToken = {\n    token: \"\",\n    expiry: 0,\n    device_id: \"\",\n};\n\nconst getToken = async () => {\n    if (cachedToken.expiry - 10 > Math.floor(new Date().getTime() / 1000)) {\n        return cachedToken.token;\n    }\n\n    const randomDeviceId = crypto.randomUUID().toUpperCase();\n\n    const anonymOauth = new URL(oauthUrl);\n    anonymOauth.searchParams.set(\"client_id\", clientId);\n    anonymOauth.searchParams.set(\"client_secret\", clientSecret);\n    anonymOauth.searchParams.set(\"device_id\", randomDeviceId);\n\n    const oauthResponse = await fetch(anonymOauth.toString(), {\n        headers: {\n            \"user-agent\": vkClientAgent,\n        }\n    }).then(r => {\n        if (r.status === 200) {\n            return r.json();\n        }\n    });\n\n    if (!oauthResponse) return;\n\n    if (oauthResponse?.token && oauthResponse?.expired_at && typeof oauthResponse?.expired_at === \"number\") {\n        cachedToken.token = oauthResponse.token;\n        cachedToken.expiry = oauthResponse.expired_at;\n        cachedToken.device_id = randomDeviceId;\n    }\n\n    if (!cachedToken.token) return;\n\n    return cachedToken.token;\n}\n\nconst getVideo = async (ownerId, videoId, accessKey) => {\n    const video = await fetch(`${apiUrl}/video.get`, {\n        method: \"POST\",\n        headers: {\n            \"content-type\": \"application/x-www-form-urlencoded; charset=utf-8\",\n            \"user-agent\": vkClientAgent,\n        },\n        body: new URLSearchParams({\n            anonymous_token: cachedToken.token,\n            device_id: cachedToken.device_id,\n            lang: \"en\",\n            v: \"5.244\",\n            videos: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`\n        }).toString()\n    })\n    .then(r => {\n        if (r.status === 200) {\n            return r.json();\n        }\n    });\n\n    return video;\n}\n\nexport default async function ({ ownerId, videoId, accessKey, quality, subtitleLang }) {\n    const token = await getToken();\n    if (!token) return { error: \"fetch.fail\" };\n\n    const videoGet = await getVideo(ownerId, videoId, accessKey);\n\n    if (!videoGet || !videoGet.response || videoGet.response.items.length !== 1) {\n        return { error: \"fetch.empty\" };\n    }\n\n    const video = videoGet.response.items[0];\n\n    if (video.restriction) {\n        const title = video.restriction.title;\n        if (title.endsWith(\"country\") || title.endsWith(\"region.\")) {\n            return { error: \"content.video.region\" };\n        }\n        if (title === \"Processing video\") {\n            return { error: \"fetch.empty\" };\n        }\n        return { error: \"content.video.unavailable\" };\n    }\n\n    if (!video.files || !video.duration) {\n        return { error: \"fetch.fail\" };\n    }\n\n    if (video.duration > env.durationLimit) {\n        return { error: \"content.too_long\" };\n    }\n\n    const userQuality = quality === \"max\" ? resolutions[0] : quality;\n    let pickedQuality;\n\n    for (const resolution of resolutions) {\n        if (video.files[`mp4_${resolution}`] && +resolution <= +userQuality) {\n            pickedQuality = resolution;\n            break\n        }\n    }\n\n    const url = video.files[`mp4_${pickedQuality}`];\n\n    if (!url) return { error: \"fetch.fail\" };\n\n    const fileMetadata = {\n        title: video.title.trim(),\n    }\n\n    let subtitles;\n    if (subtitleLang && video.subtitles?.length) {\n        const subtitle = video.subtitles.find(\n            s => s.title.endsWith(\".vtt\") && s.lang.startsWith(subtitleLang)\n        );\n        if (subtitle) {\n            subtitles = subtitle.url;\n            fileMetadata.sublanguage = subtitleLang;\n        }\n    }\n\n    return {\n        urls: url,\n        subtitles,\n        fileMetadata,\n        filenameAttributes: {\n            service: \"vk\",\n            id: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`,\n            title: fileMetadata.title,\n            resolution: `${pickedQuality}p`,\n            qualityLabel: `${pickedQuality}p`,\n            extension: \"mp4\"\n        }\n    }\n}\n"
  },
  {
    "path": "api/src/processing/services/xiaohongshu.js",
    "content": "import { resolveRedirectingURL } from \"../url.js\";\nimport { genericUserAgent } from \"../../config.js\";\nimport { createStream } from \"../../stream/manage.js\";\n\nconst https = (url) => {\n    return url.replace(/^http:/i, 'https:');\n}\n\nexport default async function ({ id, token, shareType, shareId, h265, isAudioOnly, dispatcher }) {\n    let noteId = id;\n    let xsecToken = token;\n\n    if (!noteId) {\n        const patternMatch = await resolveRedirectingURL(\n            `https://xhslink.com/${shareType}/${shareId}`,\n            dispatcher\n        );\n\n        noteId = patternMatch?.id;\n        xsecToken = patternMatch?.token;\n    }\n\n    if (!noteId || !xsecToken) return { error: \"fetch.short_link\" };\n\n    const res = await fetch(`https://www.xiaohongshu.com/explore/${noteId}?xsec_token=${xsecToken}`, {\n        headers: {\n            \"user-agent\": genericUserAgent,\n        },\n        dispatcher,\n    });\n\n    const html = await res.text();\n\n    let note;\n    try {\n        const initialState = html\n            .split('<script>window.__INITIAL_STATE__=')[1]\n            .split('</script>')[0]\n            .replace(/:\\s*undefined/g, \":null\");\n\n        const data = JSON.parse(initialState);\n\n        const noteInfo = data?.note?.noteDetailMap;\n        if (!noteInfo) throw \"no note detail map\";\n\n        const currentNote = noteInfo[noteId];\n        if (!currentNote) throw \"no current note in detail map\";\n\n        note = currentNote.note;\n    } catch {}\n\n    if (!note) return { error: \"fetch.empty\" };\n\n    const video = note.video;\n    const images = note.imageList;\n\n    const filenameBase = `xiaohongshu_${noteId}`;\n\n    if (video) {\n        const videoFilename = `${filenameBase}.mp4`;\n        const audioFilename = `${filenameBase}_audio`;\n\n        let videoURL;\n\n        if (h265 && !isAudioOnly && video.consumer?.originVideoKey) {\n            videoURL = `https://sns-video-bd.xhscdn.com/${video.consumer.originVideoKey}`;\n        } else {\n            const h264Streams = video.media?.stream?.h264;\n\n            if (h264Streams?.length) {\n                videoURL = h264Streams.reduce((a, b) => Number(a?.videoBitrate) > Number(b?.videoBitrate) ? a : b).masterUrl;\n            }\n        }\n\n        if (!videoURL) return { error: \"fetch.empty\" };\n\n        return {\n            urls: https(videoURL),\n            filename: videoFilename,\n            audioFilename: audioFilename,\n        }\n    }\n\n    if (!images || images.length === 0) {\n        return { error: \"fetch.empty\" };\n    }\n\n    if (images.length === 1) {\n        return {\n            isPhoto: true,\n            urls: https(images[0].urlDefault),\n            filename: `${filenameBase}.jpg`,\n        }\n    }\n\n    const picker = images.map((image, i) => {\n        return {\n            type: \"photo\",\n            url: createStream({\n                service: \"xiaohongshu\",\n                type: \"proxy\",\n                url: https(image.urlDefault),\n                filename: `${filenameBase}_${i + 1}.jpg`,\n            })\n        }\n    });\n\n    return { picker };\n}\n"
  },
  {
    "path": "api/src/processing/services/youtube.js",
    "content": "import HLS from \"hls-parser\";\n\nimport { fetch } from \"undici\";\nimport { Innertube, Session } from \"youtubei.js\";\n\nimport { env } from \"../../config.js\";\nimport { getCookie } from \"../cookie/manager.js\";\nimport { getYouTubeSession } from \"../helpers/youtube-session.js\";\n\nconst PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms\n\nlet innertube, lastRefreshedAt;\n\nconst codecList = {\n    h264: {\n        videoCodec: \"avc1\",\n        audioCodec: \"mp4a\",\n        container: \"mp4\"\n    },\n    av1: {\n        videoCodec: \"av01\",\n        audioCodec: \"opus\",\n        container: \"webm\"\n    },\n    vp9: {\n        videoCodec: \"vp9\",\n        audioCodec: \"opus\",\n        container: \"webm\"\n    }\n}\n\nconst hlsCodecList = {\n    h264: {\n        videoCodec: \"avc1\",\n        audioCodec: \"mp4a\",\n        container: \"mp4\"\n    },\n    vp9: {\n        videoCodec: \"vp09\",\n        audioCodec: \"mp4a\",\n        container: \"webm\"\n    }\n}\n\nconst clientsWithNoCipher = ['IOS', 'ANDROID', 'YTSTUDIO_ANDROID', 'YTMUSIC_ANDROID'];\n\nconst videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320];\n\nconst cloneInnertube = async (customFetch, useSession) => {\n    const shouldRefreshPlayer = lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date();\n\n    const rawCookie = getCookie('youtube');\n    const cookie = rawCookie?.toString();\n\n    const sessionTokens = getYouTubeSession();\n    const retrieve_player = Boolean(sessionTokens || cookie);\n\n    if (useSession && env.ytSessionServer && !sessionTokens?.potoken) {\n        throw \"no_session_tokens\";\n    }\n\n    if (!innertube || shouldRefreshPlayer) {\n        innertube = await Innertube.create({\n            fetch: customFetch,\n            retrieve_player,\n            cookie,\n            po_token: useSession ? sessionTokens?.potoken : undefined,\n            visitor_data: useSession ? sessionTokens?.visitor_data : undefined,\n        });\n        lastRefreshedAt = +new Date();\n    }\n\n    const session = new Session(\n        innertube.session.context,\n        innertube.session.api_key,\n        innertube.session.api_version,\n        innertube.session.account_index,\n        innertube.session.config_data,\n        innertube.session.player,\n        cookie,\n        customFetch ?? innertube.session.http.fetch,\n        innertube.session.cache,\n        sessionTokens?.potoken\n    );\n\n    const yt = new Innertube(session);\n    return yt;\n}\n\nconst getHlsVariants = async (hlsManifest, dispatcher) => {\n    if (!hlsManifest) {\n        return { error: \"youtube.no_hls_streams\" };\n    }\n\n    const fetchedHlsManifest =\n        await fetch(hlsManifest, { dispatcher })\n            .then(r => r.status === 200 ? r.text() : undefined)\n            .catch(() => {});\n\n    if (!fetchedHlsManifest) {\n        return { error: \"youtube.no_hls_streams\" };\n    }\n\n    const variants = HLS.parse(fetchedHlsManifest).variants.sort(\n        (a, b) => Number(b.bandwidth) - Number(a.bandwidth)\n    );\n\n    if (!variants || variants.length === 0) {\n        return { error: \"youtube.no_hls_streams\" };\n    }\n\n    return variants;\n}\n\nconst getSubtitles = async (info, dispatcher, subtitleLang) => {\n    const preferredCap = info.captions.caption_tracks.find(caption =>\n        caption.kind !== 'asr' && caption.language_code.startsWith(subtitleLang)\n    );\n\n    const captionsUrl = preferredCap?.base_url;\n    if (!captionsUrl) return;\n\n    if (!captionsUrl.includes(\"exp=xpe\")) {\n        let url = new URL(captionsUrl);\n        url.searchParams.set('fmt', 'vtt');\n\n        return {\n            url: url.toString(),\n            language: preferredCap.language_code,\n        }\n    }\n\n    // if we have exp=xpe in the url, then captions are\n    // locked down and can't be accessed without a yummy potoken,\n    // so instead we just use subtitles from HLS\n\n    const hlsVariants = await getHlsVariants(\n        info.streaming_data.hls_manifest_url,\n        dispatcher\n    );\n    if (hlsVariants?.error) return;\n\n    // all variants usually have the same set of subtitles\n    const hlsSubtitles = hlsVariants[0]?.subtitles;\n    if (!hlsSubtitles?.length) return;\n\n    const preferredHls = hlsSubtitles.find(\n        subtitle => subtitle.language.startsWith(subtitleLang)\n    );\n\n    if (!preferredHls) return;\n\n    const fetchedHlsSubs =\n        await fetch(preferredHls.uri, { dispatcher })\n            .then(r => r.status === 200 ? r.text() : undefined)\n            .catch(() => {});\n\n    const parsedSubs = HLS.parse(fetchedHlsSubs);\n    if (!parsedSubs) return;\n\n    return {\n        url: parsedSubs.segments[0]?.uri,\n        language: preferredHls.language,\n    }\n}\n\nexport default async function (o) {\n    const quality = o.quality === \"max\" ? 9000 : Number(o.quality);\n\n    let useHLS = o.youtubeHLS;\n    let innertubeClient = o.innertubeClient || env.customInnertubeClient || \"IOS\";\n\n    // HLS playlists from the iOS client don't contain the av1 video format.\n    if (useHLS && o.codec === \"av1\") {\n        useHLS = false;\n    }\n\n    if (useHLS) {\n        innertubeClient = \"IOS\";\n    }\n\n    // iOS client doesn't have adaptive formats of resolution >1080p,\n    // so we use the WEB_EMBEDDED client instead for those cases\n    let useSession =\n        env.ytSessionServer && (\n            (\n                !useHLS\n                && innertubeClient === \"IOS\"\n                && (\n                    (quality > 1080 && o.codec !== \"h264\")\n                    || (quality > 1080 && o.codec !== \"vp9\")\n                )\n            )\n        );\n\n    // we can get subtitles reliably only from the iOS client\n    if (o.subtitleLang) {\n        innertubeClient = \"IOS\";\n        useSession = false;\n    }\n\n    if (useSession) {\n        innertubeClient = env.ytSessionInnertubeClient || \"WEB_EMBEDDED\";\n    }\n\n    let yt;\n    try {\n        yt = await cloneInnertube(\n            (input, init) => fetch(input, {\n                ...init,\n                dispatcher: o.dispatcher\n            }),\n            useSession\n        );\n    } catch (e) {\n        if (e === \"no_session_tokens\") {\n            return { error: \"youtube.no_session_tokens\" };\n        } else if (e.message?.endsWith(\"decipher algorithm\")) {\n            return { error: \"youtube.decipher\" }\n        } else if (e.message?.includes(\"refresh access token\")) {\n            return { error: \"youtube.token_expired\" }\n        } else throw e;\n    }\n\n    let info;\n    try {\n        info = await yt.getBasicInfo(o.id, { client: innertubeClient });\n    } catch (e) {\n        if (e?.info) {\n            let errorInfo;\n            try { errorInfo = JSON.parse(e?.info); } catch {}\n\n            if (errorInfo?.reason === \"This video is private\") {\n                return { error: \"content.video.private\" };\n            }\n            if ([\"INVALID_ARGUMENT\", \"UNAUTHENTICATED\"].includes(errorInfo?.error?.status)) {\n                return { error: \"youtube.api_error\" };\n            }\n        }\n\n        if (e?.message === \"This video is unavailable\") {\n            return { error: \"content.video.unavailable\" };\n        }\n\n        return { error: \"fetch.fail\" };\n    }\n\n    if (!info) return { error: \"fetch.fail\" };\n\n    const playability = info.playability_status;\n    const basicInfo = info.basic_info;\n\n    switch (playability.status) {\n        case \"LOGIN_REQUIRED\":\n            if (playability.reason.endsWith(\"bot\")) {\n                return { error: \"youtube.login\" }\n            }\n            if (playability.reason.endsWith(\"age\") || playability.reason.endsWith(\"inappropriate for some users.\")) {\n                return { error: \"content.video.age\" }\n            }\n            if (playability?.error_screen?.reason?.text === \"Private video\") {\n                return { error: \"content.video.private\" }\n            }\n            break;\n\n        case \"UNPLAYABLE\":\n            if (playability?.reason?.endsWith(\"request limit.\")) {\n                return { error: \"fetch.rate\" }\n            }\n            if (playability?.error_screen?.subreason?.text?.endsWith(\"in your country\")) {\n                return { error: \"content.video.region\" }\n            }\n            if (playability?.error_screen?.reason?.text === \"Private video\") {\n                return { error: \"content.video.private\" }\n            }\n            break;\n\n        case \"AGE_VERIFICATION_REQUIRED\":\n            return { error: \"content.video.age\" };\n    }\n\n    if (playability.status !== \"OK\") {\n        return { error: \"content.video.unavailable\" };\n    }\n\n    if (basicInfo.is_live) {\n        return { error: \"content.video.live\" };\n    }\n\n    if (basicInfo.duration > env.durationLimit) {\n        return { error: \"content.too_long\" };\n    }\n\n    // return a critical error if returned video is \"Video Not Available\"\n    // or a similar stub by youtube\n    if (basicInfo.id !== o.id) {\n        return {\n            error: \"fetch.fail\",\n            critical: true\n        }\n    }\n\n    const normalizeQuality = res => {\n        const shortestSide = Math.min(res.height, res.width);\n        return videoQualities.find(qual => qual >= shortestSide);\n    }\n\n    let video, audio, subtitles, dubbedLanguage,\n        codec = o.codec || \"h264\", itag = o.itag;\n\n    if (useHLS) {\n        const variants = await getHlsVariants(\n            info.streaming_data.hls_manifest_url,\n            o.dispatcher\n        );\n\n        if (variants?.error) return variants;\n\n        const matchHlsCodec = codecs => (\n            codecs.includes(hlsCodecList[codec].videoCodec)\n        );\n\n        const best = variants.find(i => matchHlsCodec(i.codecs));\n\n        const preferred = variants.find(i =>\n            matchHlsCodec(i.codecs) && normalizeQuality(i.resolution) === quality\n        );\n\n        let selected = preferred || best;\n\n        if (!selected) {\n            codec = \"h264\";\n            selected = variants.find(i => matchHlsCodec(i.codecs));\n        }\n\n        if (!selected) {\n            return { error: \"youtube.no_matching_format\" };\n        }\n\n        audio = selected.audio.find(i => i.isDefault);\n\n        // some videos (mainly those with AI dubs) don't have any tracks marked as default\n        // why? god knows, but we assume that a default track is marked as such in the title\n        if (!audio) {\n            audio = selected.audio.find(i => i.name.endsWith(\"original\"));\n        }\n\n        if (o.dubLang) {\n            const dubbedAudio = selected.audio.find(i =>\n                i.language?.startsWith(o.dubLang)\n            );\n\n            if (dubbedAudio && !dubbedAudio.isDefault) {\n                dubbedLanguage = dubbedAudio.language;\n                audio = dubbedAudio;\n            }\n        }\n\n        selected.audio = [];\n        selected.subtitles = [];\n        video = selected;\n    } else {\n        // i miss typescript so bad\n        const sorted_formats = {\n            h264: {\n                video: [],\n                audio: [],\n                bestVideo: undefined,\n                bestAudio: undefined,\n            },\n            vp9: {\n                video: [],\n                audio: [],\n                bestVideo: undefined,\n                bestAudio: undefined,\n            },\n            av1: {\n                video: [],\n                audio: [],\n                bestVideo: undefined,\n                bestAudio: undefined,\n            },\n        }\n\n        const checkFormat = (format, pCodec) => format.content_length &&\n            (format.mime_type.includes(codecList[pCodec].videoCodec)\n                || format.mime_type.includes(codecList[pCodec].audioCodec));\n\n        // sort formats & weed out bad ones\n        info.streaming_data.adaptive_formats.sort((a, b) =>\n            Number(b.bitrate) - Number(a.bitrate)\n        ).forEach(format => {\n            Object.keys(codecList).forEach(yCodec => {\n                const matchingItag = slot => !itag?.[slot] || itag[slot] === format.itag;\n                const sorted = sorted_formats[yCodec];\n                const goodFormat = checkFormat(format, yCodec);\n                if (!goodFormat) return;\n\n                if (format.has_video && matchingItag('video')) {\n                    sorted.video.push(format);\n                    if (!sorted.bestVideo)\n                        sorted.bestVideo = format;\n                }\n\n                if (format.has_audio && matchingItag('audio')) {\n                    sorted.audio.push(format);\n                    if (!sorted.bestAudio)\n                        sorted.bestAudio = format;\n                }\n            })\n        });\n\n        const noBestMedia = () => {\n            const vid = sorted_formats[codec]?.bestVideo;\n            const aud = sorted_formats[codec]?.bestAudio;\n            return (!vid && !o.isAudioOnly) || (!aud && o.isAudioOnly)\n        };\n\n        if (noBestMedia()) {\n            if (codec === \"av1\") codec = \"vp9\";\n            else if (codec === \"vp9\") codec = \"av1\";\n\n            // if there's no higher quality fallback, then use h264\n            if (noBestMedia()) codec = \"h264\";\n        }\n\n        // if there's no proper combo of av1, vp9, or h264, then give up\n        if (noBestMedia()) {\n            return { error: \"youtube.no_matching_format\" };\n        }\n\n        audio = sorted_formats[codec].bestAudio;\n\n        if (audio?.audio_track && !audio?.is_original) {\n            audio = sorted_formats[codec].audio.find(i =>\n                i?.is_original\n            );\n        }\n\n        if (o.dubLang) {\n            const dubbedAudio = sorted_formats[codec].audio.find(i =>\n                i.language?.startsWith(o.dubLang) && i.audio_track\n            );\n\n            if (dubbedAudio && !dubbedAudio?.is_original) {\n                audio = dubbedAudio;\n                dubbedLanguage = dubbedAudio.language;\n            }\n        }\n\n        if (!o.isAudioOnly) {\n            const qual = (i) => {\n                return normalizeQuality({\n                    width: i.width,\n                    height: i.height,\n                })\n            }\n\n            const bestQuality = qual(sorted_formats[codec].bestVideo);\n            const useBestQuality = quality >= bestQuality;\n\n            video = useBestQuality\n                ? sorted_formats[codec].bestVideo\n                : sorted_formats[codec].video.find(i => qual(i) === quality);\n\n            if (!video) video = sorted_formats[codec].bestVideo;\n        }\n\n        if (o.subtitleLang && !o.isAudioOnly && info.captions?.caption_tracks?.length) {\n            const videoSubtitles = await getSubtitles(info, o.dispatcher, o.subtitleLang);\n            if (videoSubtitles) {\n                subtitles = videoSubtitles;\n            }\n        }\n    }\n\n    if (video?.drm_families || audio?.drm_families) {\n        return { error: \"youtube.drm\" };\n    }\n\n    const fileMetadata = {\n        title: basicInfo.title.trim(),\n        artist: basicInfo.author.replace(\"- Topic\", \"\").trim()\n    }\n\n    if (basicInfo?.short_description?.startsWith(\"Provided to YouTube by\")) {\n        const descItems = basicInfo.short_description.split(\"\\n\\n\", 5);\n\n        if (descItems.length === 5) {\n            fileMetadata.album = descItems[2];\n            fileMetadata.copyright = descItems[3];\n            if (descItems[4].startsWith(\"Released on:\")) {\n                fileMetadata.date = descItems[4].replace(\"Released on: \", '').trim();\n            }\n        }\n    }\n\n    if (subtitles) {\n        fileMetadata.sublanguage = subtitles.language;\n    }\n\n    const filenameAttributes = {\n        service: \"youtube\",\n        id: o.id,\n        title: fileMetadata.title,\n        author: fileMetadata.artist,\n        youtubeDubName: dubbedLanguage || false,\n    }\n\n    itag = {\n        video: video?.itag,\n        audio: audio?.itag\n    };\n\n    const originalRequest = {\n        ...o,\n        dispatcher: undefined,\n        itag,\n        innertubeClient\n    };\n\n    if (audio && o.isAudioOnly) {\n        let bestAudio = codec === \"h264\" ? \"m4a\" : \"opus\";\n        let urls = audio.url;\n\n        if (useHLS) {\n            bestAudio = \"mp3\";\n            urls = audio.uri;\n        }\n\n        if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {\n            urls = audio.decipher(innertube.session.player);\n        }\n\n        let cover = `https://i.ytimg.com/vi/${o.id}/maxresdefault.jpg`;\n        const testMaxCover = await fetch(cover, { dispatcher: o.dispatcher })\n            .then(r => r.status === 200)\n            .catch(() => {});\n\n        if (!testMaxCover) {\n            cover = basicInfo.thumbnail?.[0]?.url;\n        }\n\n        return {\n            type: \"audio\",\n            isAudioOnly: true,\n            urls,\n            filenameAttributes,\n            fileMetadata,\n            bestAudio,\n            isHLS: useHLS,\n            originalRequest,\n\n            cover,\n            cropCover: basicInfo.author.endsWith(\"- Topic\"),\n        }\n    }\n\n    if (video && audio) {\n        let resolution;\n\n        if (useHLS) {\n            resolution = normalizeQuality(video.resolution);\n            filenameAttributes.resolution = `${video.resolution.width}x${video.resolution.height}`;\n            filenameAttributes.extension = o.container === \"auto\" ? hlsCodecList[codec].container : o.container;\n\n            video = video.uri;\n            audio = audio.uri;\n        } else {\n            resolution = normalizeQuality({\n                width: video.width,\n                height: video.height,\n            });\n\n            filenameAttributes.resolution = `${video.width}x${video.height}`;\n            filenameAttributes.extension = o.container === \"auto\" ? codecList[codec].container : o.container;\n\n            if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {\n                video = video.decipher(innertube.session.player);\n                audio = audio.decipher(innertube.session.player);\n            } else {\n                video = video.url;\n                audio = audio.url;\n            }\n        }\n\n        filenameAttributes.qualityLabel = `${resolution}p`;\n        filenameAttributes.youtubeFormat = codec;\n\n        return {\n            type: \"merge\",\n            urls: [\n                video,\n                audio,\n            ],\n            subtitles: subtitles?.url,\n            filenameAttributes,\n            fileMetadata,\n            isHLS: useHLS,\n            originalRequest\n        }\n    }\n\n    return { error: \"youtube.no_matching_format\" };\n}\n"
  },
  {
    "path": "api/src/processing/url.js",
    "content": "import psl from \"@imput/psl\";\nimport { strict as assert } from \"node:assert\";\n\nimport { env } from \"../config.js\";\nimport { services } from \"./service-config.js\";\nimport { getRedirectingURL } from \"../misc/utils.js\";\nimport { friendlyServiceName } from \"./service-alias.js\";\n\nfunction aliasURL(url) {\n    assert(url instanceof URL);\n\n    const host = psl.parse(url.hostname);\n    const parts = url.pathname.split('/');\n\n    switch (host.sld) {\n        case \"youtube\":\n            if (url.pathname.startsWith('/live/') || url.pathname.startsWith('/shorts/')) {\n                url.pathname = '/watch';\n                // parts := ['', 'live' || 'shorts', id, ...rest]\n                url.search = `?v=${encodeURIComponent(parts[2])}`;\n            }\n            break;\n\n        case \"youtu\":\n            if (url.hostname === 'youtu.be' && parts.length >= 2) {\n                /* youtu.be urls can be weird, e.g. https://youtu.be/<id>//asdasd// still works\n                ** but we only care about the 1st segment of the path */\n                url = new URL(`https://youtube.com/watch?v=${\n                    encodeURIComponent(parts[1])\n                }`)\n            }\n            break;\n\n        case \"pin\":\n            if (url.hostname === 'pin.it' && parts.length === 2) {\n                url = new URL(`https://pinterest.com/url_shortener/${\n                    encodeURIComponent(parts[1])\n                }`)\n            }\n            break;\n\n        case \"vxtwitter\":\n        case \"fixvx\":\n        case \"x\":\n            if (services.twitter.altDomains.includes(url.hostname)) {\n                url.hostname = 'twitter.com';\n            }\n            break;\n\n        case \"twitch\":\n            if (url.hostname === 'clips.twitch.tv' && parts.length >= 2) {\n                url = new URL(`https://twitch.tv/_/clip/${parts[1]}`);\n            }\n            break;\n\n        case \"bilibili\":\n            if (host.tld === 'tv') {\n                url = new URL(`https://bilibili.com/_tv${url.pathname}`);\n            }\n            break;\n\n        case \"b23\":\n            if (url.hostname === 'b23.tv' && parts.length === 2) {\n                url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`);\n            }\n            break;\n\n        case \"dai\":\n            if (url.hostname === 'dai.ly' && parts.length === 2) {\n                url = new URL(`https://dailymotion.com/video/${parts[1]}`);\n            }\n            break;\n\n        case \"facebook\":\n        case \"fb\":\n            if (url.searchParams.get('v')) {\n                url = new URL(`https://web.facebook.com/user/videos/${url.searchParams.get('v')}`);\n            }\n            if (url.hostname === 'fb.watch') {\n                url = new URL(`https://web.facebook.com/_shortLink/${parts[1]}`);\n            }\n            break;\n\n        case \"ddinstagram\":\n            if (services.instagram.altDomains.includes(host.domain) && [null, 'd', 'g'].includes(host.subdomain)) {\n                url.hostname = 'instagram.com';\n            }\n            break;\n\n        case \"vk\":\n        case \"vkvideo\":\n            if (services.vk.altDomains.includes(url.hostname)) {\n                url.hostname = 'vk.com';\n            }\n            if (url.searchParams.get('z')) {\n                url = new URL(`https://vk.com/${url.searchParams.get('z')}`);\n            }\n            break;\n\n        case \"xhslink\":\n            if (url.hostname === 'xhslink.com' && parts.length === 3) {\n                url = new URL(`https://www.xiaohongshu.com/${parts[1]}/${parts[2]}`);\n            }\n            break;\n\n        case \"loom\":\n            const idPart = parts[parts.length - 1];\n            if (idPart.length > 32) {\n                url.pathname = `/share/${idPart.slice(-32)}`;\n            }\n            break;\n\n        case \"redd\":\n            /* reddit short video links can be treated by changing https://v.redd.it/<id>\n            to https://reddit.com/video/<id>.*/\n            if (url.hostname === \"v.redd.it\" && parts.length === 2) {\n                url = new URL(`https://www.reddit.com/video/${parts[1]}`);\n            }\n            break;\n    }\n\n    return url;\n}\n\nfunction cleanURL(url) {\n    assert(url instanceof URL);\n    const host = psl.parse(url.hostname).sld;\n\n    let stripQuery = true;\n\n    const limitQuery = (param) => {\n        url.search = `?${param}=` + encodeURIComponent(url.searchParams.get(param));\n        stripQuery = false;\n    }\n\n    switch (host) {\n        case \"pinterest\":\n            url.hostname = 'pinterest.com';\n            break;\n        case \"vk\":\n            if (url.pathname.includes('/clip') && url.searchParams.get('z')) {\n                limitQuery('z');\n            }\n            break;\n        case \"youtube\":\n            if (url.searchParams.get('v')) {\n                limitQuery('v');\n            }\n            break;\n        case \"bilibili\":\n        case \"rutube\":\n            if (url.searchParams.get('p')) {\n                limitQuery('p');\n            }\n            break;\n        case \"twitter\":\n            if (url.searchParams.get('post_id')) {\n                limitQuery('post_id');\n            }\n            break;\n        case \"xiaohongshu\":\n            if (url.searchParams.get('xsec_token')) {\n                limitQuery('xsec_token');\n            }\n            break;\n    }\n\n    if (stripQuery) {\n        url.search = '';\n    }\n\n    url.username = url.password = url.port = url.hash = '';\n\n    if (url.pathname.endsWith('/'))\n        url.pathname = url.pathname.slice(0, -1);\n\n    return url;\n}\n\nfunction getHostIfValid(url) {\n    const host = psl.parse(url.hostname);\n    if (host.error) return;\n\n    const service = services[host.sld];\n    if (!service) return;\n    if ((service.tld ?? 'com') !== host.tld) return;\n\n    const anySubdomainAllowed = service.subdomains === '*';\n    const validSubdomain = [null, 'www', ...(service.subdomains ?? [])].includes(host.subdomain);\n    if (!validSubdomain && !anySubdomainAllowed) return;\n\n    return host.sld;\n}\n\nexport function normalizeURL(url) {\n    return cleanURL(\n        aliasURL(\n            new URL(url.replace(/^https\\/\\//, 'https://'))\n        )\n    );\n}\n\nexport function extract(url, enabledServices = env.enabledServices) {\n    if (!(url instanceof URL)) {\n        url = new URL(url);\n    }\n\n    const host = getHostIfValid(url);\n\n    if (!host) {\n        return { error: \"link.invalid\" };\n    }\n\n    if (!enabledServices.has(host)) {\n        // show a different message when youtube is disabled on official instances\n        // as it only happens when shit hits the fan\n        if (new URL(env.apiURL).hostname.endsWith(\".imput.net\") && host === \"youtube\") {\n            return { error: \"youtube.temporary_disabled\" };\n        }\n        return { error: \"service.disabled\" };\n    }\n\n    let patternMatch;\n    for (const pattern of services[host].patterns) {\n        patternMatch = pattern.match(\n            url.pathname.substring(1) + url.search\n        );\n\n        if (patternMatch) {\n            break;\n        }\n    }\n\n    if (!patternMatch) {\n        return {\n            error: \"link.unsupported\",\n            context: {\n                service: friendlyServiceName(host),\n            }\n        };\n    }\n\n    return { host, patternMatch };\n}\n\nexport async function resolveRedirectingURL(url, dispatcher, headers) {\n    const originalService = getHostIfValid(normalizeURL(url));\n    if (!originalService) return;\n\n    const canonicalURL = await getRedirectingURL(url, dispatcher, headers);\n    if (!canonicalURL) return;\n\n    const { host, patternMatch } = extract(normalizeURL(canonicalURL));\n\n    if (host === originalService) {\n        return patternMatch;\n    }\n}\n"
  },
  {
    "path": "api/src/security/api-keys.js",
    "content": "import { env } from \"../config.js\";\nimport { Green, Yellow } from \"../misc/console-text.js\";\nimport ip from \"ipaddr.js\";\nimport * as cluster from \"../misc/cluster.js\";\nimport { FileWatcher } from \"../misc/file-watcher.js\";\n\n// this function is a modified variation of code\n// from https://stackoverflow.com/a/32402438/14855621\nconst generateWildcardRegex = rule => {\n    var escapeRegex = (str) => str.replace(/([.*+?^=!:${}()|\\[\\]\\/\\\\])/g, \"\\\\$1\");\n    return new RegExp(\"^\" + rule.split(\"*\").map(escapeRegex).join(\".*\") + \"$\");\n}\n\nconst UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;\n\nlet keys = {}, reader = null;\n\nconst ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit', 'allowedServices']);\n\n/* Expected format pseudotype:\n** type KeyFileContents = Record<\n**    UUIDv4String,\n**    {\n**        name?: string,\n**        limit?: number | \"unlimited\",\n**        ips?: CIDRString[],\n**        userAgents?: string[],\n**        allowedServices?: \"all\" | string[],\n**    }\n** >;\n*/\n\nconst validateKeys = (input) => {\n    if (typeof input !== 'object' || input === null) {\n        throw \"input is not an object\";\n    }\n\n    if (Object.keys(input).some(x => !UUID_REGEX.test(x))) {\n        throw \"key file contains invalid key(s)\";\n    }\n\n    Object.values(input).forEach(details => {\n        if (typeof details !== 'object' || details === null) {\n            throw \"some key(s) are incorrectly configured\";\n        }\n\n        const unexpected_key = Object.keys(details).find(k => !ALLOWED_KEYS.has(k));\n        if (unexpected_key) {\n            throw \"detail object contains unexpected key: \" + unexpected_key;\n        }\n\n        if (details.limit && details.limit !== 'unlimited') {\n            if (typeof details.limit !== 'number')\n                throw \"detail object contains invalid limit (not a number)\";\n            else if (details.limit < 1)\n                throw \"detail object contains invalid limit (not a positive number)\";\n        }\n\n        if (details.ips) {\n            if (!Array.isArray(details.ips))\n                throw \"details object contains value for `ips` which is not an array\";\n\n            const invalid_ip = details.ips.find(\n                addr => typeof addr !== 'string' || (!ip.isValidCIDR(addr) && !ip.isValid(addr))\n            );\n\n            if (invalid_ip) {\n                throw \"`ips` in details contains an invalid IP or CIDR range: \" + invalid_ip;\n            }\n        }\n\n        if (details.userAgents) {\n            if (!Array.isArray(details.userAgents))\n                throw \"details object contains value for `userAgents` which is not an array\";\n\n            const invalid_ua = details.userAgents.find(ua => typeof ua !== 'string');\n            if (invalid_ua) {\n                throw \"`userAgents` in details contains an invalid user agent: \" + invalid_ua;\n            }\n        }\n\n        if (details.allowedServices) {\n            if (Array.isArray(details.allowedServices)) {\n                const invalid_services = details.allowedServices.some(\n                    service => !env.allServices.has(service)\n                );\n                if (invalid_services) {\n                    throw \"`allowedServices` in details contains an invalid service\";\n                }\n            } else if (details.allowedServices !== \"all\") {\n                throw \"details object contains value for `allowedServices` which is not an array or `all`\";\n            }\n        }\n    });\n}\n\nconst formatKeys = (keyData) => {\n    const formatted = {};\n\n    for (let key in keyData) {\n        const data = keyData[key];\n        key = key.toLowerCase();\n\n        formatted[key] = {};\n\n        if (data.limit) {\n            if (data.limit === \"unlimited\") {\n                data.limit = Infinity;\n            }\n\n            formatted[key].limit = data.limit;\n        }\n\n        if (data.ips) {\n            formatted[key].ips = data.ips.map(addr => {\n                if (ip.isValid(addr)) {\n                    const parsed = ip.parse(addr);\n                    const range = parsed.kind() === 'ipv6' ? 128 : 32;\n                    return [ parsed, range ];\n                }\n\n                return ip.parseCIDR(addr);\n            });\n        }\n\n        if (data.userAgents) {\n            formatted[key].userAgents = data.userAgents.map(generateWildcardRegex);\n        }\n\n        if (data.allowedServices) {\n            if (Array.isArray(data.allowedServices)) {\n                formatted[key].allowedServices = new Set(data.allowedServices);\n            } else {\n                formatted[key].allowedServices = data.allowedServices;\n            }\n        }\n    }\n\n    return formatted;\n}\n\nconst updateKeys = (newKeys) => {\n    validateKeys(newKeys);\n\n    cluster.broadcast({ api_keys: newKeys });\n\n    keys = formatKeys(newKeys);\n}\n\nconst loadRemoteKeys = async (source) => {\n    updateKeys(\n        await fetch(source).then(a => a.json())\n    );\n}\n\nconst loadLocalKeys = async () => {\n    updateKeys(\n        JSON.parse(await reader.read())\n    );\n}\n\nconst wrapLoad = (url, initial = false) => {\n    let load = loadRemoteKeys.bind(null, url);\n\n    if (url.protocol === 'file:') {\n        if (initial) {\n            reader = FileWatcher.fromFileProtocol(url);\n            reader.on('file-updated', () => wrapLoad(url));\n        }\n\n        load = loadLocalKeys;\n    }\n\n    load().then(() => {\n        if (initial || reader) {\n            console.log(`${Green('[✓]')} api keys loaded successfully!`)\n        }\n    })\n    .catch((e) => {\n        console.error(`${Yellow('[!]')} Failed loading API keys at ${new Date().toISOString()}.`);\n        console.error('Error:', e);\n    })\n}\n\nconst err = (reason) => ({ success: false, error: reason });\n\nexport const validateAuthorization = (req) => {\n    const authHeader = req.get('Authorization');\n\n    if (typeof authHeader !== 'string') {\n        return err(\"missing\");\n    }\n\n    const [ authType, keyString ] = authHeader.split(' ', 2);\n    if (authType.toLowerCase() !== 'api-key') {\n        return err(\"not_api_key\");\n    }\n\n    if (!UUID_REGEX.test(keyString) || `${authType} ${keyString}` !== authHeader) {\n        return err(\"invalid\");\n    }\n\n    const matchingKey = keys[keyString.toLowerCase()];\n    if (!matchingKey) {\n        return err(\"not_found\");\n    }\n\n    if (matchingKey.ips) {\n        let addr;\n        try {\n            addr = ip.parse(req.ip);\n        } catch {\n            return err(\"invalid_ip\");\n        }\n\n        const ip_allowed = matchingKey.ips.some(\n            ([ allowed, size ]) => {\n                return addr.kind() === allowed.kind()\n                        && addr.match(allowed, size);\n            }\n        );\n\n        if (!ip_allowed) {\n            return err(\"ip_not_allowed\");\n        }\n    }\n\n    if (matchingKey.userAgents) {\n        const userAgent = req.get('User-Agent');\n        if (!matchingKey.userAgents.some(regex => regex.test(userAgent))) {\n            return err(\"ua_not_allowed\");\n        }\n    }\n\n    req.rateLimitKey = keyString.toLowerCase();\n    req.rateLimitMax = matchingKey.limit;\n\n    return { success: true };\n}\n\nexport const setup = (url) => {\n    if (cluster.isPrimary) {\n        wrapLoad(url, true);\n        if (env.keyReloadInterval > 0 && url.protocol !== 'file:') {\n            setInterval(() => wrapLoad(url), env.keyReloadInterval * 1000);\n        }\n    } else if (cluster.isWorker) {\n        process.on('message', (message) => {\n            if ('api_keys' in message) {\n                updateKeys(message.api_keys);\n            }\n        });\n    }\n}\n\nexport const getAllowedServices = (key) => {\n    if (typeof key !== \"string\") return;\n\n    const allowedServices = keys[key.toLowerCase()]?.allowedServices;\n    if (!allowedServices) return;\n\n    if (allowedServices === \"all\") {\n        return env.allServices;\n    }\n    return allowedServices;\n}\n"
  },
  {
    "path": "api/src/security/jwt.js",
    "content": "import { nanoid } from \"nanoid\";\nimport { createHmac } from \"crypto\";\n\nimport { env } from \"../config.js\";\n\nconst toBase64URL = (b) => Buffer.from(b).toString(\"base64url\");\nconst fromBase64URL = (b) => Buffer.from(b, \"base64url\").toString();\n\nconst makeHmac = (data) => {\n    return createHmac(\"sha256\", env.jwtSecret)\n            .update(data)\n            .digest(\"base64url\");\n}\n\nconst sign = (header, payload) =>\n        makeHmac(`${header}.${payload}`);\n\nconst getIPHash = (ip) =>\n        makeHmac(ip).slice(0, 8);\n\nconst generate = (ip) => {\n    const exp = Math.floor(new Date().getTime() / 1000) + env.jwtLifetime;\n\n    const header = toBase64URL(JSON.stringify({\n        alg: \"HS256\",\n        typ: \"JWT\"\n    }));\n\n    const payload = toBase64URL(JSON.stringify({\n        jti: nanoid(8),\n        sub: getIPHash(ip),\n        exp,\n    }));\n\n    const signature = sign(header, payload);\n\n    return {\n        token: `${header}.${payload}.${signature}`,\n        exp: env.jwtLifetime - 2,\n    };\n}\n\nconst verify = (jwt, ip) => {\n    const [header, payload, signature] = jwt.split(\".\", 3);\n    const timestamp = Math.floor(new Date().getTime() / 1000);\n\n    if ([header, payload, signature].join('.') !== jwt) {\n        return false;\n    }\n\n    const verifySignature = sign(header, payload);\n\n    if (verifySignature !== signature) {\n        return false;\n    }\n\n    const data = JSON.parse(fromBase64URL(payload));\n\n    return getIPHash(ip) === data.sub\n            && timestamp <= data.exp;\n}\n\nexport default {\n    generate,\n    verify,\n}\n"
  },
  {
    "path": "api/src/security/secrets.js",
    "content": "import cluster from \"node:cluster\";\nimport { createHmac, randomBytes } from \"node:crypto\";\n\nconst generateSalt = () => {\n    if (cluster.isPrimary)\n        return randomBytes(64);\n\n    return null;\n}\n\nlet rateSalt = generateSalt();\nlet streamSalt = generateSalt();\n\nexport const syncSecrets = () => {\n    return new Promise((resolve, reject) => {\n        if (cluster.isPrimary) {\n            let remaining = Object.values(cluster.workers).length;\n            const handleReady = (worker, m) => {\n                if (m.ready)\n                    worker.send({ rateSalt, streamSalt });\n\n                if (!--remaining)\n                    resolve();\n            }\n\n            for (const worker of Object.values(cluster.workers)) {\n                worker.once(\n                    'message',\n                    (m) => handleReady(worker, m)\n                );\n            }\n        } else if (cluster.isWorker) {\n            if (rateSalt || streamSalt)\n                return reject();\n\n            process.send({ ready: true });\n            process.once('message', (message) => {\n                if (rateSalt || streamSalt)\n                    return reject();\n\n                if (message.rateSalt && message.streamSalt) {\n                    streamSalt = Buffer.from(message.streamSalt);\n                    rateSalt = Buffer.from(message.rateSalt);\n                    resolve();\n                }\n            });\n        } else reject();\n    });\n}\n\n\nexport const hashHmac = (value, type) => {\n    let salt;\n    if (type === 'rate')\n        salt = rateSalt;\n    else if (type === 'stream')\n        salt = streamSalt;\n    else\n        throw \"unknown salt\";\n\n    return createHmac(\"sha256\", salt).update(value).digest();\n}\n"
  },
  {
    "path": "api/src/security/turnstile.js",
    "content": "import { env } from \"../config.js\";\n\nexport const verifyTurnstileToken = async (turnstileResponse, ip) => {\n    const result = await fetch(\"https://challenges.cloudflare.com/turnstile/v0/siteverify\", {\n        method: \"POST\",\n        headers: {\n            \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n            secret: env.turnstileSecret,\n            response: turnstileResponse,\n            remoteip: ip,\n        }),\n    })\n    .then(r => r.json())\n    .catch(() => {});\n\n    return !!result?.success;\n}\n"
  },
  {
    "path": "api/src/store/base-store.js",
    "content": "const _stores = new Set();\n\nexport class Store {\n    id;\n\n    constructor(name) {\n        name = name.toUpperCase();\n\n        if (_stores.has(name))\n            throw `${name} store already exists`;\n        _stores.add(name);\n\n        this.id = name;\n    }\n\n    async _has(_key) { await Promise.reject(\"needs implementation\"); }\n    has(key) {\n        if (typeof key !== 'string') {\n            key = key.toString();\n        }\n\n        return this._has(key);\n    }\n\n    async _get(_key) { await Promise.reject(\"needs implementation\"); }\n    async get(key) {\n        if (typeof key !== 'string') {\n            key = key.toString();\n        }\n\n        const val = await this._get(key);\n        if (val === null)\n            return null;\n\n        return val;\n    }\n\n    async _set(_key, _val, _exp_sec = -1) { await Promise.reject(\"needs implementation\") }\n    set(key, val, exp_sec = -1) {\n        if (typeof key !== 'string') {\n            key = key.toString();\n        }\n\n        exp_sec = Math.round(exp_sec);\n\n        return this._set(key, val, exp_sec);\n    }\n};\n"
  },
  {
    "path": "api/src/store/memory-store.js",
    "content": "import { MinPriorityQueue } from '@datastructures-js/priority-queue';\nimport { Store } from './base-store.js';\n\n// minimum delay between sweeps to avoid repeatedly\n// sweeping entries close in proximity one by one.\nconst MIN_THRESHOLD_MS = 2500;\n\nexport default class MemoryStore extends Store {\n    #store = new Map();\n    #timeouts = new MinPriorityQueue/*<{ t: number, k: unknown }>*/((obj) => obj.t);\n    #nextSweep = { id: null, t: null };\n\n    constructor(name) {\n        super(name);\n    }\n\n    _has(key) {\n        return this.#store.has(key);\n    }\n\n    _get(key) {\n        const val = this.#store.get(key);\n\n        return val === undefined ? null : val;\n    }\n\n    _set(key, val, exp_sec = -1) {\n        if (this.#store.has(key)) {\n            this.#timeouts.remove(o => o.k === key);\n        }\n\n        if (exp_sec > 0) {\n            const exp = 1000 * exp_sec;\n            const timeout_at = +new Date() + exp;\n\n            this.#timeouts.enqueue({ k: key, t: timeout_at });\n        }\n\n        this.#store.set(key, val);\n        this.#reschedule();\n    }\n\n    #reschedule() {\n        const current_time = new Date().getTime();\n        const time = this.#timeouts.front()?.t;\n        if (!time) {\n            return;\n        } else if (time < current_time) {\n            return this.#sweepNow();\n        }\n\n        const sweep = this.#nextSweep;\n        if (sweep.id === null || sweep.t > time) {\n            if (sweep.id) {\n                clearTimeout(sweep.id);\n            }\n\n            sweep.t = time;\n            sweep.id = setTimeout(\n                () => this.#sweepNow(),\n                Math.max(MIN_THRESHOLD_MS, time - current_time)\n            );\n            sweep.id.unref();\n        }\n    }\n\n    #sweepNow() {\n        while (this.#timeouts.front()?.t < new Date().getTime()) {\n            const item = this.#timeouts.dequeue();\n            this.#store.delete(item.k);\n        }\n\n        this.#nextSweep.id = null;\n        this.#nextSweep.t = null;\n        this.#reschedule();\n    }\n}\n"
  },
  {
    "path": "api/src/store/redis-ratelimit.js",
    "content": "import { env } from \"../config.js\";\n\nlet client, redis, redisLimiter;\n\nexport const createStore = async (name) => {\n    if (!env.redisURL) return;\n\n    if (!client) {\n        redis = await import('redis');\n        redisLimiter = await import('rate-limit-redis');\n        client = redis.createClient({ url: env.redisURL });\n        await client.connect();\n    }\n\n    return new redisLimiter.default({\n        prefix: `RL${name}_`,\n        sendCommand: (...args) => client.sendCommand(args),\n    });\n}\n"
  },
  {
    "path": "api/src/store/redis-store.js",
    "content": "import { commandOptions, createClient } from \"redis\";\nimport { env } from \"../config.js\";\nimport { Store } from \"./base-store.js\";\n\nexport default class RedisStore extends Store {\n    #client = createClient({\n        url: env.redisURL,\n    });\n    #connected;\n\n    constructor(name) {\n        super(name);\n        this.#connected = this.#client.connect();\n    }\n\n    #keyOf(key) {\n        return this.id + '_' + key;\n    }\n\n    async _has(key) {\n        await this.#connected;\n\n        return this.#client.hExists(key);\n    }\n\n    async _get(key) {\n        await this.#connected;\n\n        const valueType = await this.#client.get(this.#keyOf(key) + '_t');\n        const value = await this.#client.get(\n            commandOptions({ returnBuffers: true }),\n            this.#keyOf(key)\n        );\n\n        if (!value) {\n            return null;\n        }\n\n        if (valueType === 'b')\n            return value;\n        else\n            return JSON.parse(value);\n    }\n\n    async _set(key, val, exp_sec = -1) {\n        await this.#connected;\n\n        const options = exp_sec > 0 ? { EX: exp_sec } : undefined;\n\n        if (val instanceof Buffer) {\n            await this.#client.set(\n                this.#keyOf(key) + '_t',\n                'b',\n                options\n            );\n        }\n\n        await this.#client.set(\n            this.#keyOf(key),\n            val,\n            options\n        );\n    }\n}\n"
  },
  {
    "path": "api/src/store/store.js",
    "content": "import { env } from '../config.js';\n\nlet _export;\nif (env.redisURL) {\n    _export = await import('./redis-store.js');\n} else {\n    _export = await import('./memory-store.js');\n}\n\nexport default _export.default;\n"
  },
  {
    "path": "api/src/stream/ffmpeg.js",
    "content": "import ffmpeg from \"ffmpeg-static\";\nimport { spawn } from \"child_process\";\nimport { create as contentDisposition } from \"content-disposition-header\";\n\nimport { env } from \"../config.js\";\nimport { destroyInternalStream } from \"./manage.js\";\nimport { hlsExceptions } from \"../processing/service-config.js\";\nimport { closeResponse, pipe, estimateTunnelLength, estimateAudioMultiplier } from \"./shared.js\";\n\nconst metadataTags = new Set([\n    \"album\",\n    \"composer\",\n    \"genre\",\n    \"copyright\",\n    \"title\",\n    \"artist\",\n    \"album_artist\",\n    \"track\",\n    \"date\",\n    \"sublanguage\"\n]);\n\nconst convertMetadataToFFmpeg = (metadata) => {\n    const args = [];\n\n    for (const [ name, value ] of Object.entries(metadata)) {\n        if (metadataTags.has(name)) {\n            if (name === \"sublanguage\") {\n                args.push('-metadata:s:s:0', `language=${value}`);\n                continue;\n            }\n            args.push('-metadata', `${name}=${value.replace(/[\\u0000-\\u0009]/g, '')}`); // skipcq: JS-0004\n        } else {\n            throw `${name} metadata tag is not supported.`;\n        }\n    }\n\n    return args;\n}\n\nconst killProcess = (p) => {\n    p?.kill('SIGTERM'); // ask the process to terminate itself gracefully\n\n    setTimeout(() => {\n        if (p?.exitCode === null)\n            p?.kill('SIGKILL'); // brutally murder the process if it didn't quit\n    }, 5000);\n}\n\nconst getCommand = (args) => {\n    if (typeof env.processingPriority === 'number' && !isNaN(env.processingPriority)) {\n        return ['nice', ['-n', env.processingPriority.toString(), ffmpeg, ...args]]\n    }\n    return [ffmpeg, args]\n}\n\nconst render = async (res, streamInfo, ffargs, estimateMultiplier) => {\n    let process;\n    const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls];\n    const shutdown = () => (\n        killProcess(process),\n        closeResponse(res),\n        urls.map(destroyInternalStream)\n    );\n\n    try {\n        const args = [\n            '-loglevel', '-8',\n            ...ffargs,\n        ];\n\n        process = spawn(...getCommand(args), {\n            windowsHide: true,\n            stdio: [\n                'inherit', 'inherit', 'inherit',\n                'pipe'\n            ],\n        });\n\n        const [,,, muxOutput] = process.stdio;\n\n        res.setHeader('Connection', 'keep-alive');\n        res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));\n\n        res.setHeader(\n            'Estimated-Content-Length',\n            await estimateTunnelLength(streamInfo, estimateMultiplier)\n        );\n\n        pipe(muxOutput, res, shutdown);\n\n        process.on('close', shutdown);\n        res.on('finish', shutdown);\n    } catch {\n        shutdown();\n    }\n}\n\nconst remux = async (streamInfo, res) => {\n    const format = streamInfo.filename.split('.').pop();\n    const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls];\n    const args = urls.flatMap(url => ['-i', url]);\n\n    // if the stream type is merge, we expect two URLs\n    if (streamInfo.type === 'merge' && urls.length !== 2) {\n        return closeResponse(res);\n    }\n\n    if (streamInfo.subtitles) {\n        args.push(\n            '-i', streamInfo.subtitles,\n            '-map', `${urls.length}:s`,\n            '-c:s', format === 'mp4' ? 'mov_text' : 'webvtt',\n        );\n    }\n\n    if (urls.length === 2) {\n        args.push(\n            '-map', '0:v',\n            '-map', '1:a',\n        );\n    } else {\n        args.push(\n            '-map', '0:v:0',\n            '-map', '0:a:0'\n        );\n    }\n\n    args.push(\n        '-c:v', 'copy',\n        ...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy'])\n    );\n\n    if (format === 'mp4') {\n        args.push('-movflags', 'faststart+frag_keyframe+empty_moov');\n    }\n\n    if (streamInfo.type !== 'mute' && streamInfo.isHLS && hlsExceptions.has(streamInfo.service)) {\n        if (streamInfo.service === 'youtube' && format === 'webm') {\n            args.push('-c:a', 'libopus');\n        } else {\n            args.push('-c:a', 'aac', '-bsf:a', 'aac_adtstoasc');\n        }\n    }\n\n    if (streamInfo.metadata) {\n        args.push(...convertMetadataToFFmpeg(streamInfo.metadata));\n    }\n\n    args.push('-f', format === 'mkv' ? 'matroska' : format, 'pipe:3');\n\n    await render(res, streamInfo, args);\n}\n\nconst convertAudio = async (streamInfo, res) => {\n    const args = [\n        '-i', streamInfo.urls,\n        '-vn',\n        ...(streamInfo.audioCopy ? ['-c:a', 'copy'] : ['-b:a', `${streamInfo.audioBitrate}k`]),\n    ];\n\n    if (streamInfo.audioFormat === 'mp3' && streamInfo.audioBitrate === '8') {\n        args.push('-ar', '12000');\n    }\n\n    if (streamInfo.audioFormat === 'opus') {\n        args.push('-vbr', 'off');\n    }\n\n    if (streamInfo.audioFormat === 'mp4a') {\n        args.push('-movflags', 'frag_keyframe+empty_moov');\n    }\n\n    if (streamInfo.metadata) {\n        args.push(...convertMetadataToFFmpeg(streamInfo.metadata));\n    }\n\n    args.push(\n        '-f',\n        streamInfo.audioFormat === 'm4a' ? 'ipod' : streamInfo.audioFormat,\n        'pipe:3',\n    );\n\n    await render(\n        res,\n        streamInfo,\n        args,\n        estimateAudioMultiplier(streamInfo) * 1.1,\n    );\n}\n\nconst convertGif = async (streamInfo, res) => {\n    const args = [\n        '-i', streamInfo.urls,\n\n        '-vf',\n        'scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse',\n        '-loop', '0',\n\n        '-f', 'gif', 'pipe:3',\n    ];\n\n    await render(\n        res,\n        streamInfo,\n        args,\n        60,\n    );\n}\n\nexport default {\n    remux,\n    convertAudio,\n    convertGif,\n}\n"
  },
  {
    "path": "api/src/stream/internal-hls.js",
    "content": "import HLS from \"hls-parser\";\nimport { createInternalStream } from \"./manage.js\";\nimport { request } from \"undici\";\n\nfunction getURL(url) {\n    try {\n        return new URL(url);\n    } catch {\n        return null;\n    }\n}\n\nfunction transformObject(streamInfo, hlsObject) {\n    if (hlsObject === undefined) {\n        return (object) => transformObject(streamInfo, object);\n    }\n\n    let fullUrl;\n    if (getURL(hlsObject.uri)) {\n        fullUrl = new URL(hlsObject.uri);\n    } else {\n        fullUrl = new URL(hlsObject.uri, streamInfo.url);\n    }\n\n    if (fullUrl.hostname !== '127.0.0.1') {\n        hlsObject.uri = createInternalStream(fullUrl.toString(), streamInfo);\n\n        if (hlsObject.map) {\n            hlsObject.map = transformObject(streamInfo, hlsObject.map);\n        }\n    }\n\n    return hlsObject;\n}\n\nfunction transformMasterPlaylist(streamInfo, hlsPlaylist) {\n    const makeInternalStream = transformObject(streamInfo);\n\n    const makeInternalVariants = (variant) => {\n        variant = transformObject(streamInfo, variant);\n        variant.video = variant.video.map(makeInternalStream);\n        variant.audio = variant.audio.map(makeInternalStream);\n        return variant;\n    };\n    hlsPlaylist.variants = hlsPlaylist.variants.map(makeInternalVariants);\n\n    return hlsPlaylist;\n}\n\nfunction transformMediaPlaylist(streamInfo, hlsPlaylist) {\n    const makeInternalSegments = transformObject(streamInfo);\n    hlsPlaylist.segments = hlsPlaylist.segments.map(makeInternalSegments);\n    hlsPlaylist.prefetchSegments = hlsPlaylist.prefetchSegments.map(makeInternalSegments);\n    return hlsPlaylist;\n}\n\nconst HLS_MIME_TYPES = [\"application/vnd.apple.mpegurl\", \"audio/mpegurl\", \"application/x-mpegURL\"];\n\nexport function isHlsResponse(req, streamInfo) {\n    return HLS_MIME_TYPES.includes(req.headers['content-type'])\n        // bluesky's cdn responds with wrong content-type for the hls playlist,\n        // so we enforce it here until they fix it\n        || (streamInfo.service === 'bsky' && streamInfo.url.endsWith('.m3u8'));\n}\n\nexport async function handleHlsPlaylist(streamInfo, req, res) {\n    let hlsPlaylist = await req.body.text();\n    hlsPlaylist = HLS.parse(hlsPlaylist);\n\n    hlsPlaylist = hlsPlaylist.isMasterPlaylist\n        ? transformMasterPlaylist(streamInfo, hlsPlaylist)\n        : transformMediaPlaylist(streamInfo, hlsPlaylist);\n\n    hlsPlaylist = HLS.stringify(hlsPlaylist);\n\n    res.send(hlsPlaylist);\n}\n\nasync function getSegmentSize(url, config) {\n    const segmentResponse = await request(url, {\n        ...config,\n        throwOnError: true\n    });\n\n    if (segmentResponse.headers['content-length']) {\n        segmentResponse.body.dump();\n        return +segmentResponse.headers['content-length'];\n    }\n\n    // if the response does not have a content-length\n    // header, we have to compute it ourselves\n    let size = 0;\n\n    for await (const data of segmentResponse.body) {\n        size += data.length;\n    }\n\n    return size;\n}\n\nexport async function probeInternalHLSTunnel(streamInfo) {\n    const { url, headers, dispatcher, signal } = streamInfo;\n\n    // remove all falsy headers\n    Object.keys(headers).forEach(key => {\n        if (!headers[key]) delete headers[key];\n    });\n\n    const config = { headers, dispatcher, signal, maxRedirections: 16 };\n\n    const manifestResponse = await fetch(url, config);\n\n    const manifest = HLS.parse(await manifestResponse.text());\n    if (manifest.segments.length === 0)\n        return -1;\n\n    const segmentSamples = await Promise.all(\n        Array(5).fill().map(async () => {\n            const manifestIdx = Math.floor(Math.random() * manifest.segments.length);\n            const randomSegment = manifest.segments[manifestIdx];\n            if (!randomSegment.uri)\n                throw \"segment is missing URI\";\n\n            let segmentUrl;\n\n            if (getURL(randomSegment.uri)) {\n                segmentUrl = new URL(randomSegment.uri);\n            } else {\n                segmentUrl = new URL(randomSegment.uri, streamInfo.url);\n            }\n\n            const segmentSize = await getSegmentSize(segmentUrl, config) / randomSegment.duration;\n            return segmentSize;\n        })\n    );\n\n    const averageBitrate = segmentSamples.reduce((a, b) => a + b) / segmentSamples.length;\n    const totalDuration = manifest.segments.reduce((acc, segment) => acc + segment.duration, 0);\n\n    return averageBitrate * totalDuration;\n}\n"
  },
  {
    "path": "api/src/stream/internal.js",
    "content": "import { request } from \"undici\";\nimport { Readable } from \"node:stream\";\nimport { closeRequest, getHeaders, pipe } from \"./shared.js\";\nimport { handleHlsPlaylist, isHlsResponse, probeInternalHLSTunnel } from \"./internal-hls.js\";\n\nconst CHUNK_SIZE = BigInt(8e6); // 8 MB\nconst min = (a, b) => a < b ? a : b;\n\nconst serviceNeedsChunks = new Set([\"youtube\", \"vk\"]);\n\nasync function* readChunks(streamInfo, size) {\n    let read = 0n, chunksSinceTransplant = 0;\n    while (read < size) {\n        if (streamInfo.controller.signal.aborted) {\n            throw new Error(\"controller aborted\");\n        }\n\n        const chunk = await request(streamInfo.url, {\n            headers: {\n                ...getHeaders(streamInfo.service),\n                Range: `bytes=${read}-${read + CHUNK_SIZE}`\n            },\n            dispatcher: streamInfo.dispatcher,\n            signal: streamInfo.controller.signal,\n            maxRedirections: 4\n        });\n\n        if (chunk.statusCode === 403 && chunksSinceTransplant >= 3 && streamInfo.transplant) {\n            chunksSinceTransplant = 0;\n            try {\n                await streamInfo.transplant(streamInfo.dispatcher);\n                continue;\n            } catch {}\n        }\n\n        chunksSinceTransplant++;\n\n        const expected = min(CHUNK_SIZE, size - read);\n        const received = BigInt(chunk.headers['content-length']);\n\n        if (received < expected / 2n) {\n            closeRequest(streamInfo.controller);\n        }\n\n        for await (const data of chunk.body) {\n            yield data;\n        }\n\n        read += received;\n    }\n}\n\nasync function handleChunkedStream(streamInfo, res) {\n    const { signal } = streamInfo.controller;\n    const cleanup = () => (res.end(), closeRequest(streamInfo.controller));\n\n    try {\n        let req, attempts = 3;\n        while (attempts--) {\n            req = await fetch(streamInfo.url, {\n                headers: getHeaders(streamInfo.service),\n                method: 'HEAD',\n                dispatcher: streamInfo.dispatcher,\n                signal\n            });\n\n            streamInfo.url = req.url;\n            if (req.status === 403 && streamInfo.transplant) {\n                try {\n                    await streamInfo.transplant(streamInfo.dispatcher);\n                } catch {\n                    break;\n                }\n            } else break;\n        }\n\n        const size = BigInt(req.headers.get('content-length'));\n\n        if (req.status !== 200 || !size) {\n            return cleanup();\n        }\n\n        const generator = readChunks(streamInfo, size);\n\n        const abortGenerator = () => {\n            generator.return();\n            signal.removeEventListener('abort', abortGenerator);\n        }\n\n        signal.addEventListener('abort', abortGenerator);\n\n        const stream = Readable.from(generator);\n\n        for (const headerName of ['content-type', 'content-length']) {\n            const headerValue = req.headers.get(headerName);\n            if (headerValue) res.setHeader(headerName, headerValue);\n        }\n\n        pipe(stream, res, cleanup);\n    } catch {\n        cleanup();\n    }\n}\n\nasync function handleGenericStream(streamInfo, res) {\n    const { signal } = streamInfo.controller;\n    const cleanup = () => res.end();\n\n    try {\n        const fileResponse = await request(streamInfo.url, {\n            headers: {\n                ...Object.fromEntries(streamInfo.headers),\n                host: undefined\n            },\n            dispatcher: streamInfo.dispatcher,\n            signal,\n            maxRedirections: 16\n        });\n\n        res.status(fileResponse.statusCode);\n        fileResponse.body.on('error', () => {});\n\n        const isHls = isHlsResponse(fileResponse, streamInfo);\n\n        for (const [ name, value ] of Object.entries(fileResponse.headers)) {\n            if (!isHls || name.toLowerCase() !== 'content-length') {\n                res.setHeader(name, value);\n            }\n        }\n\n        if (fileResponse.statusCode < 200 || fileResponse.statusCode > 299) {\n            return cleanup();\n        }\n\n        if (isHls) {\n            await handleHlsPlaylist(streamInfo, fileResponse, res);\n        } else {\n            pipe(fileResponse.body, res, cleanup);\n        }\n    } catch {\n        closeRequest(streamInfo.controller);\n        cleanup();\n    }\n}\n\nexport function internalStream(streamInfo, res) {\n    if (streamInfo.headers) {\n        streamInfo.headers.delete('icy-metadata');\n    }\n\n    if (serviceNeedsChunks.has(streamInfo.service) && !streamInfo.isHLS) {\n        return handleChunkedStream(streamInfo, res);\n    }\n\n    return handleGenericStream(streamInfo, res);\n}\n\nexport async function probeInternalTunnel(streamInfo) {\n    try {\n        const signal = AbortSignal.timeout(3000);\n        const headers = {\n            ...Object.fromEntries(streamInfo.headers || []),\n            ...getHeaders(streamInfo.service),\n            host: undefined,\n            range: undefined\n        };\n\n        if (streamInfo.isHLS) {\n            return probeInternalHLSTunnel({\n                ...streamInfo,\n                signal,\n                headers\n            });\n        }\n\n        const response = await request(streamInfo.url, {\n            method: 'HEAD',\n            headers,\n            dispatcher: streamInfo.dispatcher,\n            signal,\n            maxRedirections: 16\n        });\n\n        if (response.statusCode !== 200)\n            throw \"status is not 200 OK\";\n\n        const size = +response.headers['content-length'];\n        if (isNaN(size))\n            throw \"content-length is not a number\";\n\n        return size;\n    } catch {}\n}\n"
  },
  {
    "path": "api/src/stream/manage.js",
    "content": "import Store from \"../store/store.js\";\n\nimport { nanoid } from \"nanoid\";\nimport { randomBytes } from \"crypto\";\nimport { strict as assert } from \"assert\";\nimport { setMaxListeners } from \"node:events\";\n\nimport { env } from \"../config.js\";\nimport { closeRequest } from \"./shared.js\";\nimport { decryptStream, encryptStream } from \"../misc/crypto.js\";\nimport { hashHmac } from \"../security/secrets.js\";\nimport { zip } from \"../misc/utils.js\";\n\n// optional dependency\nconst freebind = env.freebindCIDR && await import('freebind').catch(() => {});\n\nconst streamCache = new Store('streams');\n\nconst internalStreamCache = new Map();\n\nexport function createStream(obj) {\n    const streamID = nanoid(),\n        iv = randomBytes(16).toString('base64url'),\n        secret = randomBytes(32).toString('base64url'),\n        exp = new Date().getTime() + env.streamLifespan * 1000,\n        hmac = hashHmac(`${streamID},${exp},${iv},${secret}`, 'stream').toString('base64url'),\n        streamData = {\n            exp: exp,\n            type: obj.type,\n            urls: obj.url,\n            service: obj.service,\n            filename: obj.filename,\n\n            requestIP: obj.requestIP,\n            headers: obj.headers,\n\n            metadata: obj.fileMetadata || false,\n\n            audioBitrate: obj.audioBitrate,\n            audioCopy: !!obj.audioCopy,\n            audioFormat: obj.audioFormat,\n\n            isHLS: obj.isHLS || false,\n            originalRequest: obj.originalRequest,\n\n            // url to a subtitle file\n            subtitles: obj.subtitles,\n        };\n\n    // FIXME: this is now a Promise, but it is not awaited\n    //        here. it may happen that the stream is not\n    //        stored in the Store before it is requested.\n    streamCache.set(\n        streamID,\n        encryptStream(streamData, iv, secret),\n        env.streamLifespan\n    );\n\n    let streamLink = new URL('/tunnel', env.apiURL);\n\n    const params = {\n        'id': streamID,\n        'exp': exp,\n        'sig': hmac,\n        'sec': secret,\n        'iv': iv\n    }\n\n    for (const [key, value] of Object.entries(params)) {\n        streamLink.searchParams.append(key, value);\n    }\n\n    return streamLink.toString();\n}\n\nexport function createProxyTunnels(info) {\n    const proxyTunnels = [];\n\n    let urls = info.url;\n\n    if (typeof urls === \"string\") {\n        urls = [urls];\n    }\n\n    const tunnelTemplate = {\n        type: \"proxy\",\n        headers: info?.headers,\n        requestIP: info?.requestIP,\n    }\n\n    for (const url of urls) {\n        proxyTunnels.push(\n            createStream({\n                ...tunnelTemplate,\n                url,\n                service: info?.service,\n                originalRequest: info?.originalRequest,\n            })\n        );\n    }\n\n    if (info.subtitles) {\n        proxyTunnels.push(\n            createStream({\n                ...tunnelTemplate,\n                url: info.subtitles,\n                service: `${info?.service}-subtitles`,\n            })\n        );\n    }\n\n    if (info.cover) {\n        proxyTunnels.push(\n            createStream({\n                ...tunnelTemplate,\n                url: info.cover,\n                service: `${info?.service}-cover`,\n            })\n        );\n    }\n\n    return proxyTunnels;\n}\n\nexport function getInternalTunnel(id) {\n    return internalStreamCache.get(id);\n}\n\nexport function getInternalTunnelFromURL(url) {\n    url = new URL(url);\n    if (url.hostname !== '127.0.0.1') {\n        return;\n    }\n\n    const id = url.searchParams.get('id');\n    return getInternalTunnel(id);\n}\n\nexport function createInternalStream(url, obj = {}, isSubtitles) {\n    assert(typeof url === 'string');\n\n    let dispatcher = obj.dispatcher;\n    if (obj.requestIP) {\n        dispatcher = freebind?.dispatcherFromIP(obj.requestIP, { strict: false })\n    }\n\n    const streamID = nanoid();\n    let controller = obj.controller;\n\n    if (!controller) {\n        controller = new AbortController();\n        setMaxListeners(Infinity, controller.signal);\n    }\n\n    let headers;\n    if (obj.headers) {\n        headers = new Map(Object.entries(obj.headers));\n    }\n\n    // subtitles don't need special treatment unlike big media files\n    const service = isSubtitles ? `${obj.service}-subtitles` : obj.service;\n\n    internalStreamCache.set(streamID, {\n        url,\n        service,\n        headers,\n        controller,\n        dispatcher,\n        isHLS: obj.isHLS,\n        transplant: obj.transplant\n    });\n\n    let streamLink = new URL('/itunnel', `http://127.0.0.1:${env.tunnelPort}`);\n    streamLink.searchParams.set('id', streamID);\n\n    const cleanup = () => {\n        destroyInternalStream(streamLink);\n        controller.signal.removeEventListener('abort', cleanup);\n    }\n\n    controller.signal.addEventListener('abort', cleanup);\n\n    return streamLink.toString();\n}\n\nfunction getInternalTunnelId(url) {\n    url = new URL(url);\n    if (url.hostname !== '127.0.0.1') {\n        return;\n    }\n\n    return url.searchParams.get('id');\n}\n\nexport function destroyInternalStream(url) {\n    const id = getInternalTunnelId(url);\n\n    if (internalStreamCache.has(id)) {\n        closeRequest(getInternalTunnel(id)?.controller);\n        internalStreamCache.delete(id);\n    }\n}\n\nconst transplantInternalTunnels = function(tunnelUrls, transplantUrls) {\n    if (tunnelUrls.length !== transplantUrls.length) {\n        return;\n    }\n\n    for (const [ tun, url ] of zip(tunnelUrls, transplantUrls)) {\n        const id = getInternalTunnelId(tun);\n        const itunnel = getInternalTunnel(id);\n\n        if (!itunnel) continue;\n        itunnel.url = url;\n    }\n}\n\nconst transplantTunnel = async function (dispatcher) {\n    if (this.pendingTransplant) {\n        await this.pendingTransplant;\n        return;\n    }\n\n    let finished;\n    this.pendingTransplant = new Promise(r => finished = r);\n\n    try {\n        const handler = await import(`../processing/services/${this.service}.js`);\n        const response = await handler.default({\n            ...this.originalRequest,\n            dispatcher\n        });\n\n        if (!response.urls) {\n            return;\n        }\n\n        response.urls = [response.urls].flat();\n        if (this.originalRequest.isAudioOnly && response.urls.length > 1) {\n            response.urls = [response.urls[1]];\n        } else if (this.originalRequest.isAudioMuted) {\n            response.urls = [response.urls[0]];\n        }\n\n        const tunnels = [this.urls].flat();\n        if (tunnels.length !== response.urls.length) {\n            return;\n        }\n\n        transplantInternalTunnels(tunnels, response.urls);\n    }\n    catch {}\n    finally {\n        finished();\n        delete this.pendingTransplant;\n    }\n}\n\nfunction wrapStream(streamInfo) {\n    const url = streamInfo.urls;\n\n    if (streamInfo.originalRequest) {\n        streamInfo.transplant = transplantTunnel.bind(streamInfo);\n    }\n\n    if (typeof url === 'string') {\n        streamInfo.urls = createInternalStream(url, streamInfo);\n    } else if (Array.isArray(url)) {\n        for (const idx in streamInfo.urls) {\n            streamInfo.urls[idx] = createInternalStream(\n                streamInfo.urls[idx], streamInfo\n            );\n        }\n    } else throw 'invalid urls';\n\n    if (streamInfo.subtitles) {\n        streamInfo.subtitles = createInternalStream(\n            streamInfo.subtitles,\n            streamInfo,\n            /*isSubtitles=*/true\n        );\n    }\n\n    return streamInfo;\n}\n\nexport async function verifyStream(id, hmac, exp, secret, iv) {\n    try {\n        const ghmac = hashHmac(`${id},${exp},${iv},${secret}`, 'stream').toString('base64url');\n        const cache = await streamCache.get(id.toString());\n\n        if (ghmac !== String(hmac)) return { status: 401 };\n        if (!cache) return { status: 404 };\n\n        const streamInfo = JSON.parse(decryptStream(cache, iv, secret));\n\n        if (!streamInfo) return { status: 404 };\n\n        if (Number(exp) <= new Date().getTime())\n            return { status: 404 };\n\n        return wrapStream(streamInfo);\n    }\n    catch {\n        return { status: 500 };\n    }\n}\n"
  },
  {
    "path": "api/src/stream/proxy.js",
    "content": "import { Agent, request } from \"undici\";\nimport { create as contentDisposition } from \"content-disposition-header\";\n\nimport { destroyInternalStream } from \"./manage.js\";\nimport { getHeaders, closeRequest, closeResponse, pipe } from \"./shared.js\";\n\nconst defaultAgent = new Agent();\n\nexport default async function (streamInfo, res) {\n    const abortController = new AbortController();\n    const shutdown = () => (\n        closeRequest(abortController),\n        closeResponse(res),\n        destroyInternalStream(streamInfo.urls)\n    );\n\n    try {\n        res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');\n        res.setHeader('Content-disposition', contentDisposition(streamInfo.filename));\n\n        const { body: stream, headers, statusCode } = await request(streamInfo.urls, {\n            headers: {\n                ...getHeaders(streamInfo.service),\n                Range: streamInfo.range\n            },\n            signal: abortController.signal,\n            maxRedirections: 16,\n            dispatcher: defaultAgent,\n        });\n\n        res.status(statusCode);\n\n        for (const headerName of ['accept-ranges', 'content-type', 'content-length']) {\n            if (headers[headerName]) {\n                res.setHeader(headerName, headers[headerName]);\n            }\n        }\n\n        pipe(stream, res, shutdown);\n    } catch {\n        shutdown();\n    }\n}\n"
  },
  {
    "path": "api/src/stream/shared.js",
    "content": "import { genericUserAgent } from \"../config.js\";\nimport { vkClientAgent } from \"../processing/services/vk.js\";\nimport { getInternalTunnelFromURL } from \"./manage.js\";\nimport { probeInternalTunnel } from \"./internal.js\";\n\nconst defaultHeaders = {\n    'user-agent': genericUserAgent\n}\n\nconst serviceHeaders = {\n    bilibili: {\n        referer: 'https://www.bilibili.com/'\n    },\n    youtube: {\n        accept: '*/*',\n        origin: 'https://www.youtube.com',\n        referer: 'https://www.youtube.com',\n        DNT: '?1'\n    },\n    vk: {\n        'user-agent': vkClientAgent\n    },\n    tiktok: {\n        referer: 'https://www.tiktok.com/',\n    }\n}\n\nexport function closeRequest(controller) {\n    try { controller.abort() } catch {}\n}\n\nexport function closeResponse(res) {\n    if (!res.headersSent) {\n        res.sendStatus(500);\n    }\n\n    return res.end();\n}\n\nexport function getHeaders(service) {\n    // Converting all header values to strings\n    return Object.entries({ ...defaultHeaders, ...serviceHeaders[service] })\n        .reduce((p, [key, val]) => ({ ...p, [key]: String(val) }), {})\n}\n\nexport function pipe(from, to, done) {\n    from.on('error', done)\n        .on('close', done);\n\n    to.on('error', done)\n      .on('close', done);\n\n    from.pipe(to);\n}\n\nexport async function estimateTunnelLength(streamInfo, multiplier = 1.1) {\n    let urls = streamInfo.urls;\n    if (!Array.isArray(urls)) {\n        urls = [ urls ];\n    }\n\n    const internalTunnels = urls.map(getInternalTunnelFromURL);\n    if (internalTunnels.some(t => !t))\n        return -1;\n\n    const sizes = await Promise.all(internalTunnels.map(probeInternalTunnel));\n    const estimatedSize = sizes.reduce(\n        // if one of the sizes is missing, let's just make a very\n        // bold guess that it's the same size as the existing one\n        (acc, cur) => cur <= 0 ? acc * 2 : acc + cur,\n        0\n    );\n\n    if (isNaN(estimatedSize) || estimatedSize <= 0) {\n        return -1;\n    }\n\n    return Math.floor(estimatedSize * multiplier);\n}\n\nexport function estimateAudioMultiplier(streamInfo) {\n    if (streamInfo.audioFormat === 'wav') {\n        return 1411 / 128;\n    }\n\n    if (streamInfo.audioCopy) {\n        return 1;\n    }\n\n    return streamInfo.audioBitrate / 128;\n}\n"
  },
  {
    "path": "api/src/stream/stream.js",
    "content": "import proxy from \"./proxy.js\";\nimport ffmpeg from \"./ffmpeg.js\";\n\nimport { closeResponse } from \"./shared.js\";\nimport { internalStream } from \"./internal.js\";\n\nexport default async function(res, streamInfo) {\n    try {\n        switch (streamInfo.type) {\n            case \"proxy\":\n                return await proxy(streamInfo, res);\n\n            case \"internal\":\n                return await internalStream(streamInfo.data, res);\n\n            case \"merge\":\n            case \"remux\":\n            case \"mute\":\n                return await ffmpeg.remux(streamInfo, res);\n\n            case \"audio\":\n                return await ffmpeg.convertAudio(streamInfo, res);\n\n            case \"gif\":\n                return await ffmpeg.convertGif(streamInfo, res);\n        }\n\n        closeResponse(res);\n    } catch {\n        closeResponse(res);\n    }\n}\n"
  },
  {
    "path": "api/src/util/generate-jwt-secret.js",
    "content": "// run with `pnpm -r token:jwt`\n\nconst makeSecureString = (length = 64) => {\n    const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';\n    const out = [];\n\n    while (out.length < length) {\n        for (const byte of crypto.getRandomValues(new Uint8Array(length))) {\n            if (byte < alphabet.length) {\n                out.push(alphabet[byte]);\n            }\n\n            if (out.length === length) {\n                break;\n            }\n        }\n    }\n\n    return out.join('');\n}\n\nconsole.log(`JWT_SECRET: ${JSON.stringify(makeSecureString(64))}`)\n"
  },
  {
    "path": "api/src/util/test.js",
    "content": "import path from \"node:path\";\n\nimport { env } from \"../config.js\";\nimport { runTest } from \"../misc/run-test.js\";\nimport { loadJSON } from \"../misc/load-from-fs.js\";\nimport { Red, Bright } from \"../misc/console-text.js\";\nimport { setGlobalDispatcher, EnvHttpProxyAgent, ProxyAgent } from \"undici\";\nimport { randomizeCiphers } from \"../misc/randomize-ciphers.js\";\n\nimport { services } from \"../processing/service-config.js\";\n\nconst getTestPath = service => path.join('./src/util/tests/', `./${service}.json`);\nconst getTests = (service) => loadJSON(getTestPath(service));\n\n// services that are known to frequently fail due to external\n// factors (e.g. rate limiting)\nconst finnicky = new Set(\n    process.env.TEST_IGNORE_SERVICES\n    ? process.env.TEST_IGNORE_SERVICES.split(',')\n    : ['bilibili', 'instagram', 'facebook', 'youtube', 'vk', 'twitter', 'reddit']\n);\n\nconst runTestsFor = async (service) => {\n    const tests = getTests(service);\n    let softFails = 0, fails = 0;\n\n    if (!tests) {\n        throw \"no such service: \" + service;\n    }\n\n    for (const test of tests) {\n        const { name, url, params, expected } = test;\n        const canFail = test.canFail || finnicky.has(service);\n\n        try {\n            await runTest(url, params, expected);\n            console.log(`${service}/${name}: ok`);\n\n        } catch (e) {\n            softFails += !canFail;\n            fails++;\n\n            let failText = canFail ? `${Red('FAIL')} (ignored)` : Bright(Red('FAIL'));\n            if (canFail && process.env.GITHUB_ACTION) {\n                console.log(`::warning title=${service}/${name.replace(/,/g, ';')}::failed and was ignored`);\n            }\n\n            console.error(`${service}/${name}: ${failText}`);\n            const errorString = e.toString().split('\\n');\n            let c = '┃';\n            errorString.forEach((line, index) => {\n                line = line.replace('!=', Red('!='));\n\n                if (index === errorString.length - 1) {\n                    c = '┗';\n                }\n\n                console.error(`   ${c}`, line);\n            });\n        }\n    }\n\n    return { fails, softFails };\n}\n\nconst printHeader = (service, padLen) => {\n    const padding = padLen - service.length;\n    service = service.padEnd(1 + service.length + padding, ' ');\n    console.log(service + '='.repeat(50));\n}\n\n// TODO: remove env.externalProxy in a future version\nsetGlobalDispatcher(\n    new EnvHttpProxyAgent({ httpProxy: env.externalProxy || undefined })\n);\n\nenv.streamLifespan = 10000;\nenv.apiURL = 'http://x/';\nrandomizeCiphers();\n\nconst action = process.argv[2];\nswitch (action) {\n    case \"get-services\":\n        const fromConfig = Object.keys(services);\n\n        const missingTests = fromConfig.filter(\n            service => {\n                const tests = getTests(service);\n                return !tests || tests.length === 0\n            }\n        );\n\n        if (missingTests.length) {\n            console.error('services have no tests:', missingTests);\n            process.exitCode = 1;\n            break;\n        }\n\n        console.log(JSON.stringify(fromConfig));\n        break;\n\n    case \"run-tests-for\":\n\n        try {\n            const { softFails } = await runTestsFor(process.argv[3]);\n            process.exitCode = Number(!!softFails);\n        } catch (e) {\n            console.error(e);\n            process.exitCode = 1;\n            break;\n        }\n\n        break;\n    default:\n        const maxHeaderLen = Object.keys(services).reduce((n, v) => v.length > n ? v.length : n, 0);\n        const failCounters = {};\n\n        for (const service in services) {\n            printHeader(service, maxHeaderLen);\n            const { fails, softFails } = await runTestsFor(service);\n            failCounters[service] = fails;\n            console.log();\n\n            if (!process.exitCode && softFails)\n                process.exitCode = 1;\n        }\n\n        console.log('='.repeat(50 + maxHeaderLen));\n        console.log(\n            Bright('total fails:'),\n            Object.values(failCounters).reduce((a, b) => a + b)\n        );\n        for (const [ service, fails ] of Object.entries(failCounters)) {\n            if (fails) console.log(`${Bright(service)} fails: ${fails}`);\n        }\n}\n"
  },
  {
    "path": "api/src/util/tests/bilibili.json",
    "content": "[\n    {\n        \"name\": \"1080p video\",\n        \"url\": \"https://www.bilibili.com/video/BV18i4y1m7xV/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"1080p video muted\",\n        \"url\": \"https://www.bilibili.com/video/BV18i4y1m7xV/\",\n        \"params\": {\n            \"downloadMode\": \"mute\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"1080p vertical video\",\n        \"url\": \"https://www.bilibili.com/video/BV1uu411z7VV/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"1080p vertical video muted\",\n        \"url\": \"https://www.bilibili.com/video/BV1uu411z7VV/\",\n        \"params\": {\n            \"downloadMode\": \"mute\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"b23.tv shortlink\",\n        \"url\": \"https://b23.tv/av32430100\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"bilibili.tv link\",\n        \"url\": \"https://www.bilibili.tv/en/video/4789599404426256\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"bilibili.com link with part id\",\n        \"url\": \"https://www.bilibili.com/video/BV1uo4y1K72s?spm_id_from=333.788.videopod.episodes&p=6\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    }\n]\n"
  },
  {
    "path": "api/src/util/tests/bsky.json",
    "content": "[\n    {\n        \"name\": \"horizontal video\",\n        \"url\": \"https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3giwtwp222m\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"horizontal video, recordWithMedia\",\n        \"url\": \"https://bsky.app/profile/did:plc:ywbm3iywnhzep3ckt6efhoh7/post/3l3wonhk23g2i\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"vertical video\",\n        \"url\": \"https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3jhpomhjk2m\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"vertical video (muted)\",\n        \"url\": \"https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3jhpomhjk2m\",\n        \"params\": {\n            \"downloadMode\": \"mute\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"vertical video (audio)\",\n        \"url\": \"https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3jhpomhjk2m\",\n        \"params\": {\n            \"downloadMode\": \"audio\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"single image\",\n        \"url\": \"https://bsky.app/profile/did:plc:k4a7d65fcyevbrnntjxh57go/post/3l33flpoygt26\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"gif with a quoted post\",\n        \"url\": \"https://bsky.app/profile/imlunahey.com/post/3lgajpn5dtk2t\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"gif alone in a post\",\n        \"url\": \"https://bsky.app/profile/imlunahey.com/post/3lgah3ovxnc2q\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"several images\",\n        \"url\": \"https://bsky.app/profile/did:plc:rai7s6su2sy22ss7skouedl7/post/3kzxuxbiul626\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"picker\"\n        }\n    },\n    {\n        \"name\": \"deleted post/invalid user\",\n        \"url\": \"https://bsky.app/profile/notreal.bsky.team/post/3l2udah76ch2c\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    }\n]"
  },
  {
    "path": "api/src/util/tests/dailymotion.json",
    "content": "[\n    {\n        \"name\": \"regular video\",\n        \"url\": \"https://www.dailymotion.com/video/x8t1eho\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"private video\",\n        \"url\": \"https://www.dailymotion.com/video/k41fZWpx2TaAORA2nok\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"dai.ly shortened link\",\n        \"url\": \"https://dai.ly/k41fZWpx2TaAORA2nok\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    }\n]"
  },
  {
    "path": "api/src/util/tests/facebook.json",
    "content": "[\n    {\n        \"name\": \"direct video with username and id\",\n        \"url\": \"https://web.facebook.com/100071784061914/videos/588631943886661/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"direct video with id as query param\",\n        \"url\": \"https://web.facebook.com/watch/?v=883839773514682&ref=sharing\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"direct video with caption\",\n        \"url\": \"https://web.facebook.com/wood57/videos/𝐒𝐞𝐛𝐚𝐬𝐤𝐨𝐦-𝐟𝐮𝐥𝐥/883839773514682\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"shortlink video\",\n        \"url\": \"https://fb.watch/r1K6XHMfGT/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"reel video\",\n        \"url\": \"https://web.facebook.com/reel/730293269054758\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"shared video link\",\n        \"url\": \"https://www.facebook.com/share/v/6EJK4Z8EAEAHtz8K/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"shared video link v2\",\n        \"url\": \"https://web.facebook.com/share/r/JFZfPVgLkiJQmWrr/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    }\n]\n"
  },
  {
    "path": "api/src/util/tests/instagram.json",
    "content": "[\n    {\n        \"name\": \"single photo post\",\n        \"url\": \"https://www.instagram.com/p/DFx6KVduFWy/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"various picker (photos + video)\",\n        \"url\": \"https://www.instagram.com/p/CvYrSgnsKjv/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"picker\"\n        }\n    },\n    {\n        \"name\": \"reel\",\n        \"url\": \"https://www.instagram.com/reel/DFQe23tOWKz/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"regular video\",\n        \"url\": \"https://www.instagram.com/p/CmCVWoIr9OH/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"reel (isAudioOnly)\",\n        \"url\": \"https://www.instagram.com/reel/DFQe23tOWKz/\",\n        \"params\": {\n            \"downloadMode\": \"audio\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"reel (isAudioMuted)\",\n        \"url\": \"https://www.instagram.com/reel/DFQe23tOWKz/\",\n        \"params\": {\n            \"downloadMode\": \"mute\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"inexistent reel\",\n        \"url\": \"https://www.instagram.com/reel/XXXXXXXXXX/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    },\n    {\n        \"name\": \"inexistent post\",\n        \"url\": \"https://www.instagram.com/p/XXXXXXXXXX/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    },\n    {\n        \"name\": \"post info in an array (for whatever reason??)\",\n        \"url\": \"https://www.instagram.com/reel/CrVB9tatUDv/?igshid=blaBlABALALbLABULLSHIT==\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"prone to get rate limited\",\n        \"url\": \"https://www.instagram.com/reel/CrO-T7Qo6rq/?igshid=fuckYouNoTrackingIdForYou==\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"ddinstagram link\",\n        \"url\": \"https://ddinstagram.com/p/CmCVWoIr9OH/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"d.ddinstagram.com link\",\n        \"url\": \"https://d.ddinstagram.com/p/CmCVWoIr9OH/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"g.ddinstagram.com link\",\n        \"url\": \"https://g.ddinstagram.com/p/CmCVWoIr9OH/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"private instagram post\",\n        \"url\": \"https://www.instagram.com/p/C5_A1TQNPrYw4c2g9KAUTPUl8RVHqiAdAcOOSY0\",\n        \"canFail\": true,\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\",\n            \"errorCode\": \"error.api.content.post.private\"\n        }\n    }\n]\n"
  },
  {
    "path": "api/src/util/tests/loom.json",
    "content": "[\n    {\n        \"name\": \"1080p video\",\n        \"url\": \"https://www.loom.com/share/d165fd054a294d8a8587807bcc50c885\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"1080p video (muted)\",\n        \"url\": \"https://www.loom.com/share/d165fd054a294d8a8587807bcc50c885\",\n        \"params\": {\n            \"downloadMode\": \"mute\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"1080p video (audio only)\",\n        \"url\": \"https://www.loom.com/share/d165fd054a294d8a8587807bcc50c885\",\n        \"params\": {\n            \"downloadMode\": \"audio\"\n        },\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    },\n    {\n        \"name\": \"video with no transcodedUrl\",\n        \"url\": \"https://www.loom.com/share/aa3d8b08bee74d05af5b42989e9f33e9\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"video with title in url\",\n        \"url\": \"https://www.loom.com/share/Meet-AI-workflows-aa3d8b08bee74d05af5b42989e9f33e9\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"video with title in url (2)\",\n        \"url\": \"https://www.loom.com/share/Unlocking-Incredible-Organizational-Velocity-with-Async-Video-4a2a8baf124c4390954dcbb46a58cfd7\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    }\n]\n"
  },
  {
    "path": "api/src/util/tests/newgrounds.json",
    "content": "[\n    {\n        \"name\": \"regular video\",\n        \"url\": \"https://www.newgrounds.com/portal/view/938050\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"regular video (audio only)\",\n        \"url\": \"https://www.newgrounds.com/portal/view/938050\",\n        \"params\": {\n            \"downloadMode\": \"audio\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"regular video (muted)\",\n        \"url\": \"https://www.newgrounds.com/portal/view/938050\",\n        \"params\": {\n            \"downloadMode\": \"mute\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"regular music\",\n        \"url\": \"https://www.newgrounds.com/audio/listen/500476\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    }\n]\n"
  },
  {
    "path": "api/src/util/tests/ok.json",
    "content": "[\n    {\n        \"name\": \"regular video\",\n        \"url\": \"https://ok.ru/video/7204071410346\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    }\n]"
  },
  {
    "path": "api/src/util/tests/pinterest.json",
    "content": "[\n    {\n        \"name\": \"regular video\",\n        \"url\": \"https://www.pinterest.com/pin/70437485604616/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"invalid link\",\n        \"url\": \"https://www.pinterest.com/pin/eeeeeee/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\",\n            \"errorCode\": \"error.api.fetch.empty\"\n        }\n    },\n    {\n        \"name\": \"regular video (isAudioOnly)\",\n        \"url\": \"https://www.pinterest.com/pin/70437485604616/\",\n        \"params\": {\n            \"downloadMode\": \"audio\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"regular video (isAudioMuted)\",\n        \"url\": \"https://www.pinterest.com/pin/70437485604616/\",\n        \"params\": {\n            \"downloadMode\": \"mute\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"regular video (.ca TLD)\",\n        \"url\": \"https://www.pinterest.ca/pin/70437485604616/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"story\",\n        \"url\": \"https://www.pinterest.com/pin/gadget-cool-products-amazon-product-technology-kitchen-gadgets--1084663891475263837/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"regular picture\",\n        \"url\": \"https://www.pinterest.com/pin/412994228343400946/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"regular picture (.ca TLD)\",\n        \"url\": \"https://www.pinterest.ca/pin/412994228343400946/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"regular gif\",\n        \"url\": \"https://www.pinterest.com/pin/643170390530326178/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"regular gif (.ca TLD)\",\n        \"url\": \"https://www.pinterest.ca/pin/643170390530326178/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    }\n]"
  },
  {
    "path": "api/src/util/tests/reddit.json",
    "content": "[\n    {\n        \"name\": \"video with audio\",\n        \"url\": \"https://www.reddit.com/r/TikTokCringe/comments/wup1fg/id_be_escaping_at_the_first_chance_i_got/?utm_source=share&utm_medium=web2x&context=3\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"video with audio (isAudioOnly)\",\n        \"url\": \"https://www.reddit.com/r/TikTokCringe/comments/wup1fg/id_be_escaping_at_the_first_chance_i_got/?utm_source=share&utm_medium=web2x&context=3\",\n        \"params\": {\n            \"downloadMode\": \"audio\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"video with audio (isAudioMuted)\",\n        \"url\": \"https://www.reddit.com/r/TikTokCringe/comments/wup1fg/id_be_escaping_at_the_first_chance_i_got/?utm_source=share&utm_medium=web2x&context=3\",\n        \"params\": {\n            \"downloadMode\": \"mute\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"video without audio\",\n        \"url\": \"https://www.reddit.com/r/catvideos/comments/ftoeo7/luna_doesnt_want_to_be_bothered_while_shes_napping/?utm_source=share&utm_medium=web2x&context=3\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"actual gif, not looping video\",\n        \"url\": \"https://www.reddit.com/r/whenthe/comments/109wqy1/god_really_did_some_trolling/?utm_source=share&utm_medium=web2x&context=3\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"different audio link, live render\",\n        \"url\": \"https://www.reddit.com/r/TikTokCringe/comments/15hce91/asian_daddy_kink/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"shortened video link\",\n        \"url\": \"https://v.redd.it/ifg2emt5ck0e1\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"shortened video link (alternative)\",\n        \"url\": \"https://reddit.com/video/ifg2emt5ck0e1\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    }\n]"
  },
  {
    "path": "api/src/util/tests/rutube.json",
    "content": "[\n    {\n        \"name\": \"regular video\",\n        \"url\": \"https://rutube.ru/video/b2f6c27649907c2fde0af411b03825eb/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"vertical video (isAudioMuted)\",\n        \"url\": \"https://rutube.ru/video/18a281399b96f9184c647455a86f6724/\",\n        \"params\": {\n            \"downloadMode\": \"mute\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"russian region lock\",\n        \"url\": \"https://rutube.ru/video/b521653b4f71ece57b8ff54e57ca9b82/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    },\n    {\n        \"name\": \"vertical video\",\n        \"url\": \"https://rutube.ru/video/18a281399b96f9184c647455a86f6724/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"yappy\",\n        \"url\": \"https://rutube.ru/yappy/c8c32bf7aee04412837656ea26c2b25b/\",\n        \"canFail\": true,\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"shorts\",\n        \"url\": \"https://rutube.ru/shorts/935c1afafd0e7d52836d671967d53dac/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"vertical video (isAudioOnly)\",\n        \"url\": \"https://rutube.ru/video/18a281399b96f9184c647455a86f6724/\",\n        \"params\": {\n            \"downloadMode\": \"audio\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"vertical video (isAudioMuted)\",\n        \"url\": \"https://rutube.ru/video/18a281399b96f9184c647455a86f6724/\",\n        \"params\": {\n            \"downloadMode\": \"mute\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"private video\",\n        \"url\": \"https://rutube.ru/video/private/1161415be0e686214bb2a498165cab3e/?p=_IL1G8RSnKutunnTYwhZ5A\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"region locked video, should fail\",\n        \"canFail\": true,\n        \"url\": \"https://rutube.ru/video/e7ac82708cc22bd068a3bf6a7004d1b1/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    }\n]"
  },
  {
    "path": "api/src/util/tests/snapchat.json",
    "content": "[\n    {\n        \"name\": \"spotlight\",\n        \"url\": \"https://www.snapchat.com/spotlight/W7_EDlXWTBiXAEEniNoMPwAAYdWxucG9pZmNqAY46j0a5AY46j0YbAAAAAQ\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"shortlinked spotlight\",\n        \"url\": \"https://t.snapchat.com/4ZsiBLDi\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"story\",\n        \"url\": \"https://www.snapchat.com/add/bazerkmakane\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"picker\"\n        }\n    }\n]\n"
  },
  {
    "path": "api/src/util/tests/soundcloud.json",
    "content": "[\n    {\n        \"name\": \"public song (best)\",\n        \"url\": \"https://soundcloud.com/l2share77/loona-butterfly?utm_source=clipboard&utm_medium=text&utm_campaign=social_sharing\",\n        \"params\": {\n            \"audioFormat\": \"best\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"public song (mp3, isAudioMuted)\",\n        \"url\": \"https://soundcloud.com/l2share77/loona-butterfly?utm_source=clipboard&utm_medium=text&utm_campaign=social_sharing\",\n        \"params\": {\n            \"downloadMode\": \"mute\",\n            \"audioFormat\": \"mp3\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"private song\",\n        \"url\": \"https://soundcloud.com/user-798052861/asdasdasdsdsd/s-9TqZ7edLJ90\",\n        \"params\": {\n            \"audioFormat\": \"mp3\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"private song (wav, isAudioMuted)\",\n        \"url\": \"https://soundcloud.com/user-798052861/asdasdasdsdsd/s-9TqZ7edLJ90\",\n        \"params\": {\n            \"downloadMode\": \"mute\",\n            \"audioFormat\": \"wav\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"private song (ogg, isAudioMuted, isAudioOnly)\",\n        \"url\": \"https://soundcloud.com/user-798052861/asdasdasdsdsd/s-9TqZ7edLJ90\",\n        \"params\": {\n            \"downloadMode\": \"audio\",\n            \"audioFormat\": \"ogg\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"on.soundcloud link\",\n        \"url\": \"https://on.soundcloud.com/XHLLKSXRQ5yyGDuD9\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"on.soundcloud link, different stream type\",\n        \"url\": \"https://on.soundcloud.com/AG4c\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"no opus audio, fallback to mp3\",\n        \"url\": \"https://soundcloud.com/frums/credits\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"go+ song, should fail\",\n        \"canFail\": true,\n        \"url\": \"https://soundcloud.com/dualipa/physical-feat-troye-sivan\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    },\n    {\n        \"name\": \"region locked song, should fail\",\n        \"canFail\": true,\n        \"url\": \"https://soundcloud.com/gotye/somebody-2024-feat-kimbra\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    }\n]"
  },
  {
    "path": "api/src/util/tests/streamable.json",
    "content": "[\n    {\n        \"name\": \"regular video\",\n        \"url\": \"https://streamable.com/p9cln4\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"embedded link\",\n        \"url\": \"https://streamable.com/e/rsmo56\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"regular video (isAudioOnly)\",\n        \"url\": \"https://streamable.com/p9cln4\",\n        \"params\": {\n            \"downloadMode\": \"audio\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"regular video (isAudioMuted)\",\n        \"url\": \"https://streamable.com/p9cln4\",\n        \"params\": {\n            \"downloadMode\": \"mute\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"inexistent video\",\n        \"url\": \"https://streamable.com/XXXXXX\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    }\n]"
  },
  {
    "path": "api/src/util/tests/tiktok.json",
    "content": "[\n    {\n        \"name\": \"long link video\",\n        \"url\": \"https://www.tiktok.com/@fatfatmillycat/video/7195741644585454894\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"images\",\n        \"url\": \"https://www.tiktok.com/@matryoshk4/video/7231234675476532526\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"picker\"\n        }\n    },\n    {\n        \"name\": \"long link inexistent\",\n        \"url\": \"https://www.tiktok.com/@blablabla/video/7120851458451417478\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    },\n    {\n        \"name\": \"short link inexistent\",\n        \"url\": \"https://vt.tiktok.com/2p4ewa7/\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    },\n    {\n        \"name\": \"age restricted video\",\n        \"url\": \"https://www.tiktok.com/@.kyle.films/video/7415757181145877793\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    }\n]"
  },
  {
    "path": "api/src/util/tests/tumblr.json",
    "content": "[\n    {\n        \"name\": \"at.tumblr link\",\n        \"url\": \"https://at.tumblr.com/music/704177038274281472/n7x7pr7x4w2b\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"user subdomain link\",\n        \"url\": \"https://garfield-69.tumblr.com/post/696499862852780032\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"web app link\",\n        \"url\": \"https://www.tumblr.com/rongzhi/707729381162958848/english-added-by-me?source=share\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"tumblr audio\",\n        \"url\": \"https://www.tumblr.com/zedneon/737815079301562368/zedneon-ft-mr-sauceman-tech-n9ne-speed-of?source=share\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"tumblr video converted to audio\",\n        \"url\": \"https://garfield-69.tumblr.com/post/696499862852780032\",\n        \"params\": {\n            \"downloadMode\": \"audio\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    }\n]"
  },
  {
    "path": "api/src/util/tests/twitch.json",
    "content": "[\n    {\n        \"name\": \"clip\",\n        \"url\": \"https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"clip (isAudioOnly)\",\n        \"url\": \"https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G\",\n        \"params\": {\n            \"downloadMode\": \"audio\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"clip (isAudioMuted)\",\n        \"url\": \"https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G\",\n        \"params\": {\n            \"downloadMode\": \"mute\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"clip (mobile subdomain)\",\n        \"url\": \"https://m.twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    }\n]"
  },
  {
    "path": "api/src/util/tests/twitter.json",
    "content": "[\n    {\n        \"name\": \"regular video\",\n        \"url\": \"https://twitter.com/X/status/1697304622749086011\",\n        \"params\": {\n            \"audioFormat\": \"mp3\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"video with mobile web mediaviewer\",\n        \"url\": \"https://twitter.com/X/status/1697304622749086011/mediaViewer?currentTweet=1697304622749086011&currentTweetUser=X&currentTweet=1697304622749086011&currentTweetUser=X\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"embedded twitter video\",\n        \"url\": \"https://twitter.com/dustbin_nie/status/1624596567188717568?s=20\",\n        \"params\": {\n            \"audioFormat\": \"mp3\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"mixed media (image + gif)\",\n        \"url\": \"https://twitter.com/sky_mj26/status/1807756010712428565\",\n        \"params\": {\n            \"audioFormat\": \"mp3\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"picker\"\n        }\n    },\n    {\n        \"name\": \"picker: mixed media (video + image)\",\n        \"url\": \"https://x.com/PopCrave/status/1682176754792955905\",\n        \"params\": {\n            \"audioFormat\": \"mp3\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"picker\"\n        }\n    },\n    {\n        \"name\": \"audio from embedded twitter video (mp3, isAudioOnly)\",\n        \"url\": \"https://twitter.com/dustbin_nie/status/1624596567188717568?s=20\",\n        \"params\": {\n            \"downloadMode\": \"audio\",\n            \"audioFormat\": \"mp3\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"audio from embedded twitter video (best, isAudioOnly)\",\n        \"url\": \"https://twitter.com/dustbin_nie/status/1624596567188717568?s=20\",\n        \"params\": {\n            \"downloadMode\": \"audio\",\n            \"audioFormat\": \"best\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"audio from embedded twitter video (ogg, isAudioOnly, isAudioMuted)\",\n        \"url\": \"https://twitter.com/dustbin_nie/status/1624596567188717568?s=20\",\n        \"params\": {\n            \"downloadMode\": \"audio\",\n            \"audioFormat\": \"best\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"muted embedded twitter video\",\n        \"url\": \"https://twitter.com/dustbin_nie/status/1624596567188717568?s=20\",\n        \"params\": {\n            \"downloadMode\": \"mute\",\n            \"audioFormat\": \"mp3\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"retweeted video\",\n        \"url\": \"https://twitter.com/schlizzawg/status/1869017025055793405\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"age restricted video\",\n        \"url\": \"https://x.com/XSpaces/status/1526955853743546372\",\n        \"params\": {},\n        \"canFail\": true,\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"twitter voice + x.com link\",\n        \"url\": \"https://x.com/eggsaladscreams/status/1693089534886506756?s=46\",\n        \"params\": {},\n        \"canFail\": true,\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"vxtwitter link\",\n        \"url\": \"https://vxtwitter.com/dustbin_nie/status/1624596567188717568?s=20\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"post with 1 image\",\n        \"url\": \"https://x.com/PopCrave/status/1815960083475423235\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"post with 4 images\",\n        \"url\": \"https://x.com/PopCrave/status/1877880433242771717\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"picker\"\n        }\n    },\n    {\n        \"name\": \"retweeted video, isAudioOnly\",\n        \"url\": \"https://twitter.com/hugekiwinuts/status/1618671150829309953?s=46&t=gItGzgwGQQJJaJrO6qc1Pg\",\n        \"params\": {\n            \"downloadMode\": \"mute\",\n            \"audioFormat\": \"mp3\"\n        },\n        \"canFail\": true,\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"gif\",\n        \"url\": \"https://x.com/thelastromances/status/1897839691212202479\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"inexistent post\",\n        \"url\": \"https://twitter.com/test/status/9487653\",\n        \"params\": {\n            \"audioFormat\": \"best\"\n        },\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    },\n    {\n        \"name\": \"post with no media content\",\n        \"url\": \"https://twitter.com/elonmusk/status/1604617643973124097?s=20\",\n        \"params\": {\n            \"audioFormat\": \"best\"\n        },\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    },\n    {\n        \"name\": \"bookmarked video\",\n        \"url\": \"https://twitter.com/i/bookmarks?post_id=1828099210220294314\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"bookmarked photo\",\n        \"url\": \"https://twitter.com/i/bookmarks?post_id=1887450602164396149\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"video in an ad card\",\n        \"url\": \"https://x.com/igorbrigadir/status/1611399816487084033?s=46\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    }\n]\n"
  },
  {
    "path": "api/src/util/tests/vimeo.json",
    "content": "[\n    {\n        \"name\": \"4k progressive\",\n        \"url\": \"https://vimeo.com/288386543\",\n        \"params\": {\n            \"videoQuality\": \"2160\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"720p progressive\",\n        \"url\": \"https://vimeo.com/288386543\",\n        \"params\": {\n            \"videoQuality\": \"720\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"1080p dash parcel\",\n        \"url\": \"https://vimeo.com/967252742\",\n        \"params\": {\n            \"videoQuality\": \"1440\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"720p dash parcel\",\n        \"url\": \"https://vimeo.com/967252742\",\n        \"params\": {\n            \"videoQuality\": \"360\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"private video\",\n        \"url\": \"https://vimeo.com/903115595/f14d06da38\",\n        \"params\": {},\n        \"canFail\": true,\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    },\n    {\n        \"name\": \"mature video\",\n        \"url\": \"https://vimeo.com/973212054\",\n        \"params\": {},\n        \"canFail\": true,\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"redirect\"\n        }\n    }\n]"
  },
  {
    "path": "api/src/util/tests/vk.json",
    "content": "[\n    {\n        \"name\": \"clip, defaults\",\n        \"url\": \"https://vk.com/clip-57274055_456239788\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"clip, 360\",\n        \"url\": \"https://vk.com/clip-57274055_456239788\",\n        \"params\": {\n            \"videoQuality\": \"360\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"clip different link, max\",\n        \"url\": \"https://vk.com/clips-57274055?z=clip-57274055_456239788\",\n        \"params\": {\n            \"videoQuality\": \"max\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"video, defaults\",\n        \"url\": \"https://vk.com/video-57274055_456239399\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"big 4k video\",\n        \"url\": \"https://vk.com/video-1112285_456248465\",\n        \"params\": {\n            \"videoQuality\": \"max\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"short 4k video, 480p, vkvideo.ru domain\",\n        \"url\": \"https://vkvideo.ru/video-26006257_456245538\",\n        \"params\": {\n            \"videoQuality\": \"480\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"ancient video (fallback to 240p)\",\n        \"url\": \"https://vk.com/video-1959_28496479\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"inexistent video\",\n        \"url\": \"https://vk.com/video-53333333_456233333\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    }\n]\n"
  },
  {
    "path": "api/src/util/tests/xiaohongshu.json",
    "content": "[\n    {\n        \"name\": \"video (might have expired)\",\n        \"url\": \"https://www.xiaohongshu.com/explore/685e63e1000000000b02ee3b?xsec_token=ABN8EQJCDMPcFX9RRggeIPSHLIJ8zkGceFDyBewLGUz30=\",\n        \"canFail\": true,\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"picker with multiple live photos (might have expired)\",\n        \"url\": \"https://www.xiaohongshu.com/explore/687128a2000000001203d94c?xsec_token=CBlDi5QDXDWZu2uUmbUrpKwg8lEL3uC10mc59lGf43r9w=\",\n        \"canFail\": true,\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"picker\"\n        }\n    },\n    {\n        \"name\": \"one photo (might have expired)\",\n        \"url\": \"https://www.xiaohongshu.com/explore/64726b99000000000800e115?xsec_token=ABoD3qPHqVZolCfS-J8UP9QQaPXZ6Z6PVyODrhaiUg27U=\",\n        \"canFail\": true,\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"short link (might have expired)\",\n        \"url\": \"https://xhslink.com/m/2wAnaTkLRc1\",\n        \"canFail\": true,\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"wrong note id\",\n        \"url\": \"https://www.xiaohongshu.com/discovery/item/6789065911100000210035fc?source=webshare&xhsshare=pc_web&xsec_token=CBustnz_Twf1BSybpe5-D-BzUb-Bx28DPLb418TN9S9Kk&xsec_source=pc_share\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    },\n    {\n        \"name\": \"short link, wrong id\",\n        \"url\": \"https://xhslink.com/a/aaaaaa\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    }\n]\n"
  },
  {
    "path": "api/src/util/tests/youtube.json",
    "content": "[\n    {\n        \"name\": \"4k video (h264, 1440)\",\n        \"url\": \"https://www.youtube.com/watch?v=vPwaXytZcgI\",\n        \"params\": {\n            \"youtubeVideoCodec\": \"h264\",\n            \"videoQuality\": \"1440\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"4k video (vp9, 720)\",\n        \"url\": \"https://www.youtube.com/watch?v=vPwaXytZcgI\",\n        \"params\": {\n            \"youtubeVideoCodec\": \"vp9\",\n            \"videoQuality\": \"720\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"4k video (av1, max)\",\n        \"url\": \"https://www.youtube.com/watch?v=vPwaXytZcgI\",\n        \"params\": {\n            \"youtubeVideoCodec\": \"av1\",\n            \"videoQuality\": \"max\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"4k video (h264, 720)\",\n        \"url\": \"https://www.youtube.com/watch?v=vPwaXytZcgI\",\n        \"params\": {\n            \"youtubeVideoCodec\": \"h264\",\n            \"videoQuality\": \"720\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"4k video (vp9, max, isAudioMuted)\",\n        \"url\": \"https://www.youtube.com/watch?v=vPwaXytZcgI\",\n        \"params\": {\n            \"downloadMode\": \"mute\",\n            \"youtubeVideoCodec\": \"vp9\",\n            \"videoQuality\": \"max\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"4k video (h264, max, isAudioMuted)\",\n        \"url\": \"https://www.youtube.com/watch?v=vPwaXytZcgI\",\n        \"params\": {\n            \"downloadMode\": \"mute\",\n            \"youtubeVideoCodec\": \"h264\",\n            \"videoQuality\": \"max\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"4k video (av1, max, isAudioMuted, isAudioOnly, mp3)\",\n        \"url\": \"https://www.youtube.com/watch?v=vPwaXytZcgI\",\n        \"params\": {\n            \"downloadMode\": \"audio\",\n            \"audioFormat\": \"mp3\",\n            \"youtubeVideoCodec\": \"av1\",\n            \"videoQuality\": \"max\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"4k video (av1, max, isAudioMuted, isAudioOnly, best)\",\n        \"url\": \"https://www.youtube.com/watch?v=vPwaXytZcgI\",\n        \"params\": {\n            \"downloadMode\": \"audio\",\n            \"audioFormat\": \"best\",\n            \"youtubeVideoCodec\": \"av1\",\n            \"videoQuality\": \"max\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"music (mp3, isAudioOnly, isAudioMuted)\",\n        \"url\": \"https://music.youtube.com/watch?v=5rGTsvZCEdk&feature=share\",\n        \"params\": {\n            \"downloadMode\": \"audio\",\n            \"audioFormat\": \"mp3\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"music (mp3)\",\n        \"url\": \"https://music.youtube.com/watch?v=5rGTsvZCEdk&feature=share\",\n        \"params\": {\n            \"audioFormat\": \"mp3\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"audio bitrate higher than video, no vp9 video in response (mp3, isAudioOnly)\",\n        \"url\": \"https://www.youtube.com/watch?v=t5nC_ucYBrc\",\n        \"params\": {\n            \"downloadMode\": \"audio\",\n            \"audioFormat\": \"mp3\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"short, defaults\",\n        \"url\": \"https://www.youtube.com/shorts/r5FpeOJItbw\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"vr 360, av1, max\",\n        \"url\": \"https://www.youtube.com/watch?v=hEdzv7D4CbQ\",\n        \"params\": {\n            \"youtubeVideoCodec\": \"vp9\",\n            \"videoQuality\": \"max\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"live link, defaults\",\n        \"url\": \"https://www.youtube.com/live/ENxZS6PUDuI?feature=shared\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"inexistent video\",\n        \"url\": \"https://youtube.com/watch?v=gnjuHYWGEW\",\n        \"params\": {},\n        \"expected\": {\n            \"code\": 400,\n            \"status\": \"error\"\n        }\n    },\n    {\n        \"name\": \"broken audioOnly download\",\n        \"url\": \"https://www.youtube.com/watch?v=ink80Al5nbw\",\n        \"params\": {\n            \"downloadMode\": \"audio\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"hls video (h264, 1440p)\",\n        \"url\": \"https://www.youtube.com/watch?v=vPwaXytZcgI\",\n        \"canFail\": true,\n        \"params\": {\n            \"youtubeVideoCodec\": \"h264\",\n            \"videoQuality\": \"1440\",\n            \"youtubeHLS\": true\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"hls video (vp9, 360p)\",\n        \"url\": \"https://www.youtube.com/watch?v=vPwaXytZcgI\",\n        \"canFail\": true,\n        \"params\": {\n            \"youtubeVideoCodec\": \"vp9\",\n            \"videoQuality\": \"360\",\n            \"youtubeHLS\": true\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"hls video (audio mode)\",\n        \"url\": \"https://www.youtube.com/watch?v=vPwaXytZcgI\",\n        \"canFail\": true,\n        \"params\": {\n            \"downloadMode\": \"audio\",\n            \"youtubeHLS\": true\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    },\n    {\n        \"name\": \"hls video (audio mode, best format)\",\n        \"url\": \"https://www.youtube.com/watch?v=vPwaXytZcgI\",\n        \"canFail\": true,\n        \"params\": {\n            \"downloadMode\": \"audio\",\n            \"youtubeHLS\": true,\n            \"audioFormat\": \"best\"\n        },\n        \"expected\": {\n            \"code\": 200,\n            \"status\": \"tunnel\"\n        }\n    }\n]"
  },
  {
    "path": "docs/api-env-variables.md",
    "content": "# cobalt api instance environment variables\nyou can customize your processing instance's behavior using these environment variables. all of them but `API_URL` are optional.\nthis document is not final and will expand over time. feel free to improve it!\n\n### general vars\n| name                   | default | value example                         |\n|:-----------------------|:--------|:--------------------------------------|\n| API_URL                |         | `https://api.url.example/`            |\n| API_PORT               | `9000`  | `1337`                                |\n| COOKIE_PATH            |         | `/cookies.json`                       |\n| PROCESSING_PRIORITY    |         | `10`                                  |\n| API_INSTANCE_COUNT     |         | `6`                                   |\n| API_REDIS_URL          |         | `redis://localhost:6379`              |\n| DISABLED_SERVICES      |         | `bilibili,youtube`                    |\n| FORCE_LOCAL_PROCESSING | `never` | `always`                              |\n| API_ENV_FILE           |         | `/.env`                               |\n\n[*view details*](#general)\n\n### networking vars\n| name                | default   | value example                         |\n|:--------------------|:----------|:--------------------------------------|\n| API_LISTEN_ADDRESS  | `0.0.0.0` | `127.0.0.1`                           |\n| FREEBIND_CIDR       |           | `2001:db8::/32`                       |\n\n#### undici proxy vars\n| name        | value example                         |\n|:------------|:--------------------------------------|\n| HTTP_PROXY  | `http://user:password@10.0.0.1:1337/` |\n| HTTPS_PROXY | `https://10.0.0.2:1337/`              |\n| NO_PROXY    | `localhost`                           |\n\n[*view details*](#networking)\n\n### limit vars\n| name                     | default | value example |\n|:-------------------------|:--------|:--------------|\n| DURATION_LIMIT           | `10800` | `18000`       |\n| TUNNEL_LIFESPAN          | `90`    | `120`         |\n| RATELIMIT_WINDOW         | `60`    | `120`         |\n| RATELIMIT_MAX            | `20`    | `30`          |\n| SESSION_RATELIMIT_WINDOW | `60`    | `60`          |\n| SESSION_RATELIMIT_MAX    | `10`    | `10`          |\n| TUNNEL_RATELIMIT_WINDOW  | `60`    | `60`          |\n| TUNNEL_RATELIMIT_MAX     | `40`    | `10`          |\n\n[*view details*](#limits)\n\n### security vars\n| name              | default | value example                         |\n|:------------------|:--------|:--------------------------------------|\n| CORS_WILDCARD     | `1`     | `0`                                   |\n| CORS_URL          |         | `https://web.url.example`             |\n| TURNSTILE_SITEKEY |         | `1x00000000000000000000BB`            |\n| TURNSTILE_SECRET  |         | `1x0000000000000000000000000000000AA` |\n| JWT_SECRET        |         | see [details](#security)              |\n| JWT_EXPIRY        | `120`   | `240`                                 |\n| API_KEY_URL       |         | `file://keys.json`                    |\n| API_AUTH_REQUIRED |         | `1`                                   |\n\n[*view details*](#security)\n\n### service-specific vars\n| name                             | value example            |\n|:---------------------------------|:-------------------------|\n| CUSTOM_INNERTUBE_CLIENT          | `IOS`                    |\n| YOUTUBE_SESSION_SERVER           | `http://localhost:8080/` |\n| YOUTUBE_SESSION_INNERTUBE_CLIENT | `WEB_EMBEDDED`           |\n| YOUTUBE_ALLOW_BETTER_AUDIO       | `1`                      |\n| ENABLE_DEPRECATED_YOUTUBE_HLS    | `key`                    |\n\n[*view details*](#service-specific)\n\n## general\n[*jump to the table*](#general-vars)\n\n### API_URL\n> [!NOTE]\n> API_URL is required to run the API instance.\n\nthe URL from which your instance will be accessible. can be external or internal, but it must be a valid URL or else tunnels will not work.\n\nthe value is a URL.\n\n### API_PORT\nport from which the API server will be accessible.\n\nthe value is a number from 1024 to 65535.\n\n### COOKIE_PATH\npath to the `cookies.json` file relative to the current working directory of your cobalt instance (usually the main (src/api) folder).\n\n### PROCESSING_PRIORITY\n`nice` value for ffmpeg subprocesses. available only on unix systems.\n\nnote: the higher the nice value, the lower the priority. you can [read more about nice here](https://en.wikipedia.org/wiki/Nice_(Unix)).\n\nthe value is a number.\n\n### API_INSTANCE_COUNT\nsupported only on linux and node.js `>=23.1.0`. when configured, cobalt will spawn multiple sub-instances amongst which requests will be balanced. `API_REDIS_URL` is required to use this option.\n\nthe value is a number.\n\n### API_REDIS_URL\nwhen configured, cobalt will use this redis instance for tunnel cache. required when `API_INSTANCE_COUNT` is more than 1, because else sub-instance wouldn't be able to share cache.\n\nthe value is a URL.\n\n### DISABLED_SERVICES\ncomma-separated list which disables certain services from being used.\n\nthe value is a string of cobalt-supported services.\n\n### FORCE_LOCAL_PROCESSING\nthe value is a string: `never` (default), `session`, or `always`:\n- when the var is not defined or set to `never`, all requests will be able to set a preference via `localProcessing` in POST requests.\n- when set to `session`, only requests from session (Bearer token) clients will be forced to use on-device processing.\n- when set to `always`, all requests will be forced to use on-device processing, no matter the preference.\n\n### API_ENV_FILE\nthe URL or local path to a `key=value`-style environment variable file. this is used for dynamically reloading environment variables. **not all environment variables are able to be updated by this.** (e.g. the ratelimiters are instantiated when starting cobalt, and cannot be changed)\n\n## networking\n[*jump to the table*](#networking-vars)\n\n### API_LISTEN_ADDRESS\ndefines the local address for the api instance. if you are using a docker container, you usually don't need to configure this.\n\nthe value is a local IP address.\n\n### HTTP_PROXY, HTTPS_PROXY, NO_PROXY\nURL of the proxy that will be passed to [`EnvHttpProxyAgent`](https://undici.nodejs.org/#/docs/api/EnvHttpProxyAgent) for proxying external requests. if some cobalt functionality breaks when using a proxy, please [make a new issue](https://github.com/imputnet/cobalt/issues) about it!\n\nquoted from [undici docs](https://undici.nodejs.org/#/docs/api/EnvHttpProxyAgent):\n> When `HTTP_PROXY` and `HTTPS_PROXY` are set, `HTTP_PROXY` is used for HTTP requests and `HTTPS_PROXY` is used for HTTPS requests. If only `HTTP_PROXY` is set, `HTTP_PROXY` is used for both HTTP and HTTPS requests. If only `HTTPS_PROXY` is set, it is only used for HTTPS requests.\n\n> `NO_PROXY` is a comma or space-separated list of hostnames that should not be proxied. The list may contain leading wildcard characters (`*`). If `NO_PROXY` is set, the EnvHttpProxyAgent() will bypass the proxy for requests to hosts that match the list. If `NO_PROXY` is set to `\"*\"`, the EnvHttpProxyAgent() will bypass the proxy for all requests.\n\nthe value is a string:\n- `HTTP_PROXY`/`HTTPS_PROXY`: URL or hostname.\n- `NO_PROXY`: comma or space-separated list of hostnames.\n\n### API_EXTERNAL_PROXY (deprecated)\n> [!WARNING]\n> this env variable is deprecated and will be removed in a future release. please update your configuration to use `HTTP_PROXY` or `HTTPS_PROXY`, as mentioned above.\n\nURL of the proxy that will be passed to [`EnvHttpProxyAgent`](https://undici.nodejs.org/#/docs/api/EnvHttpProxyAgent) and used for proxying external requests. HTTP(S) only.\n\nthe value is a URL.\n\n### FREEBIND_CIDR\nIPv6 prefix used for randomly assigning addresses to cobalt requests. available only on linux systems.\n\nsetting a `FREEBIND_CIDR` allows cobalt to pick a random IP for every download and use it for all requests it makes for that particular download.\n\nto use freebind in cobalt, you need to follow its [setup instructions](https://github.com/imputnet/freebind.js?tab=readme-ov-file#setup) first.\n\nif you want to use this option and run cobalt in a docker container, you also need to set the `API_LISTEN_ADDRESS` env variable to `127.0.0.1` and set `network_mode` for the container to `host`.\n\nthe value is an IPv6 range.\n\n## limits\n[*jump to the table*](#limit-vars)\n\n### DURATION_LIMIT\nmedia duration limit, in **seconds**\n\nthe value is a number.\n\n### TUNNEL_LIFESPAN\nthe duration for which tunnel info is stored in ram, **in seconds**.\n\nit's recommended to keep this value either default or as low as possible to preserve efficiency and user privacy.\n\nthe value is a number.\n\n### RATELIMIT_WINDOW\nrate limit time window for api requests, but not session requests, in **seconds**.\n\nthe value is a number.\n\n### RATELIMIT_MAX\namount of api requests to be allowed within the time window of `RATELIMIT_WINDOW`.\n\nthe value is a number.\n\n### SESSION_RATELIMIT_WINDOW\nrate limit time window for session creation requests, in **seconds**.\n\nthe value is a number.\n\n### SESSION_RATELIMIT_MAX\namount of session requests to be allowed within the time window of `SESSION_RATELIMIT_WINDOW`.\n\nthe value is a number.\n\n### TUNNEL_RATELIMIT_WINDOW\nrate limit time window for tunnel (proxy/stream) requests, in **seconds**.\n\nthe value is a number.\n\n### TUNNEL_RATELIMIT_MAX\namount of tunnel requests to be allowed within the time window of `TUNNEL_RATELIMIT_WINDOW`.\n\nthe value is a number.\n\n## security\n[*jump to the table*](#security-vars)\n\n> [!NOTE]\n> in order to enable turnstile bot protection, `TURNSTILE_SITEKEY`, `TURNSTILE_SECRET`, and `JWT_SECRET` must be set. all three at once.\n\n### CORS_WILDCARD\ndefines whether cross-origin resource sharing is enabled. when enabled, your instance will be accessible from foreign web pages.\n\nthe value is a number, either `0` or `1`.\n\n### CORS_URL\nconfigures the [cross-origin resource sharing origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin). your instance will be available only from this URL if `CORS_WILDCARD` is set to `0`.\n\nthe value is a URL.\n\n### TURNSTILE_SITEKEY\n[cloudflare turnstile](https://www.cloudflare.com/products/turnstile/) sitekey used by the web client to request & solve a challenge to prove that the user is not a bot.\n\nthe value is a specific key.\n\n### TURNSTILE_SECRET\n[cloudflare turnstile](https://www.cloudflare.com/products/turnstile/) secret used by the processing instance to verify that the client solved the challenge successfully.\n\nthe value is a specific key.\n\n### JWT_SECRET\nthe secret used for issuing JWT tokens for request authentication. the value must be a random, secure, and long string (over 16 characters).\n\nthe value is a specific key.\n\n### JWT_EXPIRY\nthe duration of how long a cobalt-issued JWT token will remain valid, in seconds.\n\nthe value is a number.\n\n### API_KEY_URL\nthe URL to the the external or local key database. for local files you have to specify a local path using the `file://` protocol.\n\nsee [the api key section](/docs/protect-an-instance.md#api-key-file-format) in the \"how to protect your cobalt instance\" document for more details.\n\nthe value is a URL.\n\n### API_AUTH_REQUIRED\nwhen set to `1`, the user always needs to be authenticated in some way before they can access the API (either via an api key or via turnstile, if enabled).\n\nthe value is a number, either `0` or `1`.\n\n## service-specific\n[*jump to the table*](#service-specific-vars)\n\n### CUSTOM_INNERTUBE_CLIENT\ninnertube client that will be used instead of the default one.\n\nthe value is a string.\n\n### YOUTUBE_SESSION_SERVER\nURL to an instance of [yt-session-generator](https://github.com/imputnet/yt-session-generator). used for automatically pulling `poToken` & `visitor_data` for youtube. can be local or remote.\n\nthe value is a URL.\n\n### YOUTUBE_SESSION_INNERTUBE_CLIENT\ninnertube client that's compatible with botguard's (web) `poToken` and `visitor_data`.\n\nthe value is a string.\n\n### YOUTUBE_ALLOW_BETTER_AUDIO\nwhen set to `1`, cobalt will try to use higher quality audio if user requests it via `youtubeBetterAudio`. will negatively impact the rate limit of a secondary youtube client with a session.\n\nthe value is a number, either `0` or `1`.\n\n### ENABLE_DEPRECATED_YOUTUBE_HLS\nthe value is a string: `never` (default), `key`, or `always`:\n- when the var is not defined or set to `never`, `youtubeHLS` in POST requests will be ignored.\n- when set to `key`, only requests from api-key clients will be able to use `youtubeHLS` in POST requests.\n- when set to `always`, all requests will be able to use `youtubeHLS` in POST requests.\n"
  },
  {
    "path": "docs/api.md",
    "content": "# cobalt api documentation\nmethods, acceptable values, headers, responses and everything else related to making and parsing requests from a cobalt api instance.\n\n> [!IMPORTANT]\n> hosted api instances (such as `api.cobalt.tools`) use bot protection and are **not** intended to be used in other projects without explicit permission. if you want to use the cobalt api, you should [host your own instance](/docs/run-an-instance.md) or ask an instance owner for access.\n\n- [POST /](#post)\n- [POST /session](#post-session)\n- [GET /](#get)\n- [GET /tunnel](#get-tunnel)\n\nall endpoints (except for `GET /`) are rate limited and return current rate limiting status in `RateLimit-*` headers, according to the [\"RateLimit Header Fields for HTTP\" spec](https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-02.html#name-header-specifications).\n\n## authentication\nan api instance may be configured to require you to authenticate yourself.\nif this is the case, you will typically receive an [error response](#error-response)\nwith a **`api.auth.<method>.missing`** code, which tells you that a particular method\nof authentication is required.\n\nauthentication is done by passing the `Authorization` header, containing\nthe authentication scheme and the token:\n```\nAuthorization: <scheme> <token>\n```\n\ncurrently, cobalt supports two ways of authentication. an instance can\nchoose to configure both, or neither:\n- [`Api-Key`](#api-key-authentication)\n- [`Bearer`](#bearer-authentication)\n\n### api-key authentication\nthe api key authentication is the most straightforward. the instance owner\nwill assign you an api key which you can then use to authenticate like so:\n```\nAuthorization: Api-Key aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\n```\n\nif you are an instance owner and wish to configure api key authentication,\nsee the [instance](run-an-instance.md#api-key-file-format) documentation!\n\n### bearer authentication\nthe cobalt server may be configured to issue JWT bearers, which are short-lived\ntokens intended for use by regular users (e.g. after passing a challenge).\ncurrently, cobalt can issue tokens for successfully solved [turnstile](run-an-instance.md#list-of-all-environment-variables)\nchallenge, if the instance has turnstile configured. the resulting token is passed like so:\n```\nAuthorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\n```\n\n## POST `/`\ncobalt's main processing endpoint.\n\n> [!IMPORTANT]\n> you must include correct `Accept` and `Content-Type` headers with every `POST /` request.\n\n```\nAccept: application/json\nContent-Type: application/json\n```\n\n### request body\nbody type: `application/json`\n\nnot a fan of reading tables of text?\nyou can read [the api schema](/api/src/processing/schema.js) directly from code instead!\n\n### api schema\nall keys except for `url` are optional. value options are separated by `/`.\n\n#### general\n| key               | type      | description/value                                               | default    |\n|:------------------|:----------|:----------------------------------------------------------------|:-----------|\n| `url`             | `string`  | source URL                                                      | *required* |\n| `audioBitrate`    | `string`  | `320 / 256 / 128 / 96 / 64 / 8` (kbps)                          | `128`      |\n| `audioFormat`     | `string`  | `best / mp3 / ogg / wav / opus`                                 | `mp3`      |\n| `downloadMode`    | `string`  | `auto / audio / mute`                                           | `auto`     |\n| `filenameStyle`   | `string`  | `classic / pretty / basic / nerdy`                              | `basic`    |\n| `videoQuality`    | `string`  | `max / 4320 / 2160 / 1440 / 1080 / 720 / 480 / 360 / 240 / 144` | `1080`     |\n| `disableMetadata` | `boolean` | title, artist, and other info will not be added to the file     | `false`    |\n| `alwaysProxy`     | `boolean` | always tunnel all files, even when not necessary                | `false`    |\n| `localProcessing` | `string`  | `disabled / preferred / forced`                                 | `disabled` |\n| `subtitleLang`    | `string`  | any valid ISO 639-1 language code                               | *none*     |\n\n#### service-specific options\n| key                     | type      | description/value                                 | default |\n|:------------------------|:----------|:--------------------------------------------------|:--------|\n| `youtubeVideoCodec`     | `string`  | `h264 / av1 / vp9`                                | `h264`  |\n| `youtubeVideoContainer` | `string`  | `auto / mp4 / webm / mkv`                         | `auto`  |\n| `youtubeDubLang`        | `string`  | any valid ISO 639-1 language code                 | *none*  |\n| `convertGif`            | `boolean` | convert twitter gifs to the actual GIF format     | `true`  |\n| `allowH265`             | `boolean` | allow H265/HEVC videos from tiktok/xiaohongshu    | `false` |\n| `tiktokFullAudio`       | `boolean` | download the original sound used in a video       | `false` |\n| `youtubeBetterAudio`    | `boolean` | prefer higher quality youtube audio if possible   | `false` |\n| `youtubeHLS`            | `boolean` | use HLS formats when downloading from youtube     | `false` |\n\n### response\nbody type: `application/json`\n\nthe response will always be a JSON object containing the `status` key, which is one of:\n- `tunnel`: cobalt is proxying and/or remuxing/transcoding the file for you.\n- `local-processing`: cobalt is proxying the files for you, but you have to remux/transcode them locally.\n- `redirect`: cobalt will redirect you to the direct service URL.\n- `picker`: there are multiple items to choose from, a picker should be shown.\n- `error`: something went wrong, here's an error code.\n\n### tunnel/redirect response\n| key          | type     | value                                                      |\n|:-------------|:---------|:-----------------------------------------------------------|\n| `status`     | `string` | `tunnel / redirect`                                        |\n| `url`        | `string` | url for the cobalt tunnel, or redirect to an external link |\n| `filename`   | `string` | cobalt-generated filename for the file being downloaded    |\n\n### local processing response\n| key          | type       | value                                                         |\n|:-------------|:-----------|:--------------------------------------------------------------|\n| `status`     | `string`   | `local-processing`                                            |\n| `type`       | `string`   | `merge`, `mute`, `audio`, `gif`, or `remux`                   |\n| `service`    | `string`   | origin service (`youtube`, `twitter`, `instagram`, etc)       |\n| `tunnel`     | `string[]` | array of tunnel URLs                                          |\n| `output`     | `object`   | details about the output file ([see below](#output-object))   |\n| `audio`      | `object`   | audio-specific details (optional, [see below](#audio-object)) |\n| `isHLS`      | `boolean`  | whether the output is in HLS format (optional)                |\n\n#### output object\n| key         | type      | value                                                                             |\n|:------------|:----------|:----------------------------------------------------------------------------------|\n| `type`      | `string`  | mime type of the output file                                                      |\n| `filename`  | `string`  | filename of the output file                                                       |\n| `metadata`  | `object`  | metadata associated with the file (optional, [see below](#outputmetadata-object)) |\n| `subtitles` | `boolean` | whether tunnels include a subtitle file                                           |\n\n#### output.metadata object\nall keys in this table are optional.\n\n| key            | type     | description                                |\n|:---------------|:---------|:-------------------------------------------|\n| `album`        | `string` | album name or collection title             |\n| `composer`     | `string` | composer of the track                      |\n| `genre`        | `string` | track's genre(s)                           |\n| `copyright`    | `string` | copyright information or ownership details |\n| `title`        | `string` | title of the track or media file           |\n| `artist`       | `string` | artist or creator name                     |\n| `album_artist` | `string` | album's artist or creator name             |\n| `track`        | `string` | track number or position in album          |\n| `date`         | `string` | release date or creation date              |\n| `sublanguage`  | `string` | subtitle language code (ISO 639-2)         |\n\n#### audio object\n| key         | type      | value                                                      |\n|:------------|:----------|:-----------------------------------------------------------|\n| `copy`      | `boolean` | defines whether audio codec data is copied                 |\n| `format`    | `string`  | output audio format                                        |\n| `bitrate`   | `string`  | preferred bitrate of audio format                          |\n| `cover`     | `boolean` | whether tunnels include a cover art file (optional)        |\n| `cropCover` | `boolean` | whether cover art should be cropped to a square (optional) |\n\n### picker response\n| key             | type     | value                                                                                          |\n|:----------------|:---------|:-----------------------------------------------------------------------------------------------|\n| `status`        | `string` | `picker`                                                                                       |\n| `audio`         | `string` | returned when an image slideshow (such as on tiktok) has a general background audio (optional) |\n| `audioFilename` | `string` | cobalt-generated filename, returned if `audio` exists (optional)                               |\n| `picker`        | `array`  | array of objects containing the individual media                                               |\n\n#### picker object\n| key          | type      | value                     |\n|:-------------|:----------|:--------------------------|\n| `type`       | `string`  | `photo` / `video` / `gif` |\n| `url`        | `string`  |                           |\n| `thumb`      | `string`  | thumbnail url (optional)  |\n\n### error response\n| key          | type     | value                         |\n|:-------------|:---------|:------------------------------|\n| `status`     | `string` | `error`                       |\n| `error`      | `object` | error code & optional context |\n\n#### error object\n| key          | type     | value                                                     |\n|:-------------|:---------|:----------------------------------------------------------|\n| `code`       | `string` | machine-readable error code explaining the failure reason |\n| `context`    | `object` | additional error context (optional)                       |\n\n#### error.context object\n| key          | type     | value                                                                       |\n|:-------------|:---------|:----------------------------------------------------------------------------|\n| `service`    | `string` | origin service (optional)                                                   |\n| `limit`      | `number` | the maximum downloadable video duration or the rate limit window (optional) |\n\n## POST `/session`\nused for generating JWT tokens, if enabled. currently, cobalt only supports\ngenerating tokens when a [turnstile](run-an-instance.md#list-of-all-environment-variables) challenge solution\nis submitted by the client.\n\nthe turnstile challenge response is submitted via the `cf-turnstile-response` header.\n\n### response body\n| key             | type       | description                                            |\n|:----------------|:-----------|:-------------------------------------------------------|\n| `token`         | `string`   | a `Bearer` token used for later request authentication |\n| `exp`           | `number`   | number in seconds indicating the token lifetime        |\n\non failure, an [error response](#error-response) is returned.\n\n## GET `/`\nprovides basic instance info.\n\n### response\nbody type: `application/json`\n\n| key         | type     | description                                              |\n|:------------|:---------|:---------------------------------------------------------|\n| `cobalt`    | `object` | information about the cobalt instance                    |\n| `git`       | `object` | information about the codebase that is currently running |\n\n#### cobalt object\n| key                | type       | description                                    |\n|:-------------------|:-----------|:-----------------------------------------------|\n| `version`          | `string`   | cobalt version                                 |\n| `url`              | `string`   | instance url                                   |\n| `startTime`        | `string`   | instance start time in unix milliseconds       |\n| `turnstileSitekey` | `string`   | site key for a turnstile widget (optional)     |\n| `services`         | `string[]` | array of services which this instance supports |\n\n#### git object\n| key         | type     | description |\n|:------------|:---------|:------------|\n| `commit`    | `string` | commit hash |\n| `branch`    | `string` | git branch  |\n| `remote`    | `string` | git remote  |\n\n## GET `/tunnel`\nendpoint for file tunnels (proxy/remux/transcode). the response is a file stream. all errors are reported via\n[HTTP status codes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status).\n\n### returned headers\n- `Content-Length`: file size, in bytes. returned when exact final file size is known.\n- `Estimated-Content-Length`: estimated file size, in bytes. returned when real `Content-Length` is not known.\na rough estimate which should NOT be used for strict size verification.\ncan be used to show approximate download progress in UI.\n\n### possible HTTP status codes\n- 200: OK\n- 401: Unauthorized\n- 403: Bad Request\n- 404: Not Found\n- 429: Too Many Requests (rate limit exceeded, check [RateLimit-* headers](https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-02.html#name-header-specifications))\n- 500: Internal Server Error.\n"
  },
  {
    "path": "docs/examples/cookies.example.json",
    "content": "{\n    \"instagram\": [\n        \"mid=<replace>; ig_did=<with>; csrftoken=<your>; ds_user_id=<own>; sessionid=<cookies>\"\n    ],\n    \"instagram_bearer\": [\n        \"token=<token_with_no_bearer_in_front>\", \"token=IGT:2:<looks_like_this>\"\n    ],\n    \"reddit\": [\n        \"client_id=<replace_this>; client_secret=<replace_this>; refresh_token=<replace_this>\"\n    ],\n    \"twitter\": [\n        \"auth_token=<replace_this>; ct0=<replace_this>\"\n    ],\n    \"youtube\": [\n        \"cookie=<replace_this>; b=<replace_this>\"\n    ],\n    \"vimeo\": [\n        \"access_token=<replace_this>\"\n    ]\n}\n"
  },
  {
    "path": "docs/examples/docker-compose.example.yml",
    "content": "services:\n    cobalt:\n        image: ghcr.io/imputnet/cobalt:11\n\n        init: true\n        read_only: true\n        restart: unless-stopped\n        container_name: cobalt\n\n        ports:\n            - 9000:9000/tcp\n            # if you use a reverse proxy (such as nginx),\n            # uncomment the next line and remove the one above (9000:9000/tcp):\n            # - 127.0.0.1:9000:9000\n\n        environment:\n            # replace https://api.url.example/ with your instance's url\n            # or else tunneling functionality won't work properly\n            API_URL: \"https://api.url.example/\"\n\n            # if you want to use cookies for fetching data from services,\n            # uncomment the next line & volumes section\n            # COOKIE_PATH: \"/cookies.json\"\n\n            # it's recommended to configure bot protection or api keys if the instance is public,\n            # see /docs/protect-an-instance.md for more info\n\n            # see /docs/run-an-instance.md for more variables that you can use here\n\n        labels:\n            - com.centurylinklabs.watchtower.scope=cobalt\n\n        # uncomment only if you use the COOKIE_PATH variable\n        # volumes:\n            # - ./cookies.json:/cookies.json\n\n    # watchtower updates the cobalt image automatically\n    watchtower:\n        image: ghcr.io/containrrr/watchtower\n        restart: unless-stopped\n        command: --cleanup --scope cobalt --interval 900 --include-restarting\n        volumes:\n            - /var/run/docker.sock:/var/run/docker.sock\n\n    # if needed, use this image for automatically generating poToken & visitor_data\n    # yt-session-generator:\n    #     image: ghcr.io/imputnet/yt-session-generator:webserver\n\n    #     init: true\n    #     restart: unless-stopped\n    #     container_name: yt-session-generator\n    #     labels:\n    #       - com.centurylinklabs.watchtower.scope=cobalt\n"
  },
  {
    "path": "docs/protect-an-instance.md",
    "content": "# how to protect your cobalt instance\nif you keep getting a ton of unknown traffic that hurts the performance of your instance, then it might be a good idea to enable bot protection.\n\n> [!NOTE]\n> this tutorial will work reliably on the latest official version of cobalt 10.\nwe can't promise full compatibility with anything else.\n\n## configure cloudflare turnstile\nturnstile is a free, safe, and privacy-respecting alternative to captcha.\ncobalt uses it automatically to weed out bots and automated scripts.\nyour instance doesn't have to be proxied by cloudflare to use turnstile.\nall you need is a free cloudflare account to get started.\n\ncloudflare dashboard interface might change over time, but basics should stay the same.\n\n> [!WARNING]\n> never share the turnstile secret key, always keep it private. if accidentally exposed, rotate it in widget settings.\n\n1. open [the cloudflare dashboard](https://dash.cloudflare.com/) and log into your account\n\n2. once logged in, select `Turnstile` in the sidebar\n<div align=\"left\">\n    <p>\n        <img src=\"images/protect-an-instance/sidebar.png\" width=\"250\" />\n    </p>\n</div>\n\n3. press `Add widget`\n<div align=\"left\">\n    <p>\n        <img src=\"images/protect-an-instance/add.png\" width=\"550\" />\n    </p>\n</div>\n\n4. enter the widget name (can be anything, such as \"cobalt\")\n<div align=\"left\">\n    <p>\n        <img src=\"images/protect-an-instance/name.png\" width=\"450\" />\n    </p>\n</div>\n\n5. add cobalt frontend domains you want the widget to work with, you can change this list later at any time\n    - if you want to use your processing instance with [cobalt.tools](https://cobalt.tools/) frontend, then add `cobalt.tools` to the list\n<div align=\"left\">\n    <p>\n        <img src=\"images/protect-an-instance/domain.png\" width=\"450\" />\n    </p>\n</div>\n\n6. select `invisible` widget mode\n<div align=\"left\">\n    <p>\n        <img src=\"images/protect-an-instance/mode.png\" width=\"450\" />\n    </p>\n</div>\n\n7. press `create`\n\n8. keep the page with sitekey and secret key open, you'll need them later.\nif you closed it, no worries!\njust open the same turnstile page and press \"settings\" on your freshly made turnstile widget.\n\n<div align=\"left\">\n    <p>\n        <img src=\"images/protect-an-instance/created.png\" width=\"450\" />\n    </p>\n</div>\n\nyou've successfully created a turnstile widget!\ntime to add it to your processing instance.\n\n### enable turnstile on your processing instance\nthis tutorial assumes that you only have `API_URL` in your `environment` variables list.\nif you have other variables there, just add new ones after existing ones.\n\n> [!CAUTION]\n> never use any values from the tutorial, especially `JWT_SECRET`!\n\n1. open your `docker-compose.yml` config file in any text editor of choice.\n2. copy the turnstile sitekey & secret key and paste them to their respective variables.\n`TURNSTILE_SITEKEY` for the sitekey and `TURNSTILE_SECRET` for the secret key:\n```yml\nenvironment:\n    API_URL: \"https://your.instance.url.here.local/\"\n    TURNSTILE_SITEKEY: \"2x00000000000000000000BB\" # use your key\n    TURNSTILE_SECRET: \"2x0000000000000000000000000000000AA\" # use your key\n```\n3. generate a `JWT_SECRET`. we recommend using an alphanumeric collection with a length of at least 64 characters.\nthis string will be used as salt for all JWT keys.\n\n    you can generate a random secret with `pnpm -r token:jwt` or use any other that you like.\n\n```yml\nenvironment:\n    API_URL: \"https://your.instance.url.here.local/\"\n    TURNSTILE_SITEKEY: \"2x00000000000000000000BB\" # use your key\n    TURNSTILE_SECRET: \"2x0000000000000000000000000000000AA\" # use your key\n    JWT_SECRET: \"bgBmF4efNCKPirD\" # create a new secret, NEVER use this one\n```\n4. restart the docker container.\n\n## configure api keys\nif you want to use your instance outside of web interface, you'll need an api key!\n\n> [!NOTE]\n> this tutorial assumes that you'll keep your keys file locally, on the instance server.\n> if you wish to upload your file to a remote location,\n> replace the value for `API_KEYS_URL` with a direct url to the file\n> and skip the second step.\n\n> [!WARNING]\n> when storing keys file remotely, make sure that it's not publicly accessible\n> and that link to it is either authenticated (via query) or impossible to guess.\n>\n> if api keys leak, you'll have to update/remove all UUIDs to revoke them.\n\n1. create a `keys.json` file following [the schema and example down below](#api-key-file-format).\n\n2. expose the `keys.json` to the docker container:\n```yml\nvolumes:\n    - ./keys.json:/keys.json:ro # ro - read-only\n```\n\n3. add a path to the keys file to container environment:\n```yml\nenvironment:\n    # ... other variables here ...\n    API_KEY_URL: \"file:///keys.json\"\n```\n\n4. restart the docker container.\n\n## limit access to an instance with api keys but no turnstile\nby default, api keys are additional, meaning that they're not *required*,\nbut work alongside with turnstile or no auth (regular ip hash rate limiting).\n\nto always require auth (via keys or turnstile, if configured), set `API_AUTH_REQUIRED` to 1:\n```yml\nenvironment:\n    # ... other variables here ...\n    API_AUTH_REQUIRED: 1\n```\n\n- if both keys and turnstile are enabled, then nothing will change.\n- if only keys are configured, then all requests without a valid api key will be refused.\n\n### why not make keys exclusive by default?\nkeys may be useful for going around rate limiting,\nwhile keeping the rest of api rate limited, with no turnstile in place.\n\n## api key file format\nthe file is a JSON-serialized object with the following structure:\n```typescript\n\ntype KeyFileContents = Record<\n    UUIDv4String,\n    {\n        name?: string,\n        limit?: number | \"unlimited\",\n        ips?: (CIDRString | IPString)[],\n        userAgents?: string[],\n        allowedServices?: \"all\" | string[],\n    }\n>;\n```\n\nwhere *`UUIDv4String`* is a stringified version of a UUIDv4 identifier.\n- **name** is a field for your own reference, it is not used by cobalt anywhere.\n\n- **`limit`** specifies how many requests the API key can make during the window specified in the `RATELIMIT_WINDOW` env.\n    - when omitted, the limit specified in `RATELIMIT_MAX` will be used.\n    - it can be also set to `\"unlimited\"`, in which case the API key bypasses all rate limits.\n\n- **`ips`** contains an array of allowlisted IP ranges, which can be specified both as individual ips or CIDR ranges (e.g. *`[\"192.168.42.69\", \"2001:db8::48\", \"10.0.0.0/8\", \"fe80::/10\"]`*).\n    - when specified, only requests from these ip ranges can use the specified api key.\n    - when omitted, any IP can be used to make requests with that API key.\n\n- **`userAgents`** contains an array of allowed user agents, with support for wildcards (e.g. *`[\"cobaltbot/1.0\", \"Mozilla/5.0 * Chrome/*\"]`*).\n    - when specified, requests with a `user-agent` that does not appear in this array will be rejected.\n    - when omitted, any user agent can be specified to make requests with that API key.\n\n- **`allowedServices`** is an array of allowed services or `\"all\"`.\n    - when `\"all\"` is specified, the key will be able to access all supported services, even if they're globally disabled via `DISABLED_SERVICES`.\n    - when an array of services is specified, the key will be able to access only the services included in the array.\n    - when omitted, the key will use the global list of supported services.\n\n- if both `ips` and `userAgents` are set, the tokens will be limited by both parameters.\n- if cobalt detects any problem with your key file, it will be ignored and a warning will be printed to the console.\n\nan example key file could look like this:\n```json\n{\n    \"b5c7160a-b655-4c7a-b500-de839f094550\": {\n        \"limit\": 10,\n        \"ips\": [\"10.0.0.0/8\", \"192.168.42.42\"],\n        \"userAgents\": [\"*Chrome*\"]\n    },\n    \"b00b1234-a3e5-99b1-c6d1-dba4512ae190\": {\n        \"limit\": \"unlimited\",\n        \"ips\": [\"192.168.1.2\"],\n        \"userAgents\": [\"cobaltbot/1.0\"]\n    }\n}\n```\n\nif you are configuring a key file, **do not use the UUID from the example** but instead generate your own. you can do this by running the following command if you have node.js installed:\n`node -e \"console.log(crypto.randomUUID())\"`\n"
  },
  {
    "path": "docs/run-an-instance.md",
    "content": "# how to run a cobalt instance\nthis tutorial will help you run your own cobalt processing instance. if your instance is public-facing, we highly recommend that you also [protect it from abuse](/docs/protect-an-instance.md) using turnstile or api keys or both.\n\n## using docker compose and package from github (recommended)\nto run the cobalt docker package, you need to have `docker` and `docker-compose` installed and configured.\n\nif you need help with installing docker, you can find more information here:\n- [how to install docker](https://docs.docker.com/engine/install/)\n- [how to install docker compose](https://docs.docker.com/compose/install/)\n\n## how to run a cobalt docker package:\n1. create a folder for cobalt config file, something like this:\n    ```sh\n    mkdir cobalt\n    ```\n\n2. go to cobalt folder, and create a docker compose config file:\n    ```sh\n    cd cobalt && nano docker-compose.yml\n    ```\n    i'm using `nano` in this example, it may not be available in your distro. you can use any other text editor.\n\n3. copy and paste the [sample config from here](examples/docker-compose.example.yml) and edit it to your needs.\n    make sure to replace default URLs with your own or cobalt won't work correctly.\n\n4. finally, start the cobalt container (from cobalt directory):\n    ```sh\n    docker compose up -d\n    ```\n\nif you want your instance to support services that require authentication to view public content, create `cookies.json` file in the same directory as `docker-compose.yml`. example cookies file [can be found here](examples/cookies.example.json).\n\ncobalt package will update automatically thanks to watchtower.\n\nit's highly recommended to use a reverse proxy (such as nginx) if you want your instance to face the public internet. look up tutorials online.\n\n## run cobalt api outside of docker (useful for local development)\nrequirements:\n- node.js >= 18\n- git\n- pnpm\n\n1. clone the repo: `git clone https://github.com/imputnet/cobalt`.\n2. go to api directory: `cd cobalt/api`.\n3. install dependencies: `pnpm install`.\n4. create `.env` file in the same directory.\n5. add needed environment variables to `.env` file. only `API_URL` is required to run cobalt.\n    - if you don't know what api url to use for local development, use `http://localhost:9000/`.\n6. run cobalt: `pnpm start`.\n\n### ubuntu 22.04 workaround\n`nscd` needs to be installed and running so that the `ffmpeg-static` binary can resolve DNS ([#101](https://github.com/imputnet/cobalt/issues/101#issuecomment-1494822258)):\n\n```bash\nsudo apt install nscd\nsudo service nscd start\n```\n\n## list of environment variables\n[this section has moved](/docs/api-env-variables.md) to a dedicated document that is way easier to understand and maintain. go check it out!\n"
  },
  {
    "path": "package.json",
    "content": "{\n    \"name\": \"cobalt\",\n    \"packageManager\": \"pnpm@9.6.0\",\n    \"engines\": {\n        \"pnpm\": \">=9\"\n    }\n}"
  },
  {
    "path": "packages/api-client/.gitignore",
    "content": "dist\n"
  },
  {
    "path": "packages/api-client/.prettierignore",
    "content": "# Ignore artifacts:\ndist\n"
  },
  {
    "path": "packages/api-client/.prettierrc",
    "content": "{\n    \"tabWidth\": 4,\n    \"singleQuote\": true,\n    \"trailingComma\": \"none\",\n    \"arrowParens\": \"avoid\"\n}\n"
  },
  {
    "path": "packages/api-client/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 imput\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "packages/api-client/package.json",
    "content": "{\n    \"name\": \"@imput/cobalt-client\",\n    \"version\": \"0.0.1\",\n    \"description\": \"\",\n    \"main\": \"index.js\",\n    \"scripts\": {},\n    \"keywords\": [],\n    \"author\": \"imput <meow@imput.net>\",\n    \"license\": \"MIT\",\n    \"devDependencies\": {\n        \"prettier\": \"3.3.3\",\n        \"tsup\": \"^8.3.0\",\n        \"typescript\": \"^5.4.5\"\n    }\n}\n"
  },
  {
    "path": "packages/api-client/tsconfig.json",
    "content": "{\n    \"include\": [\"src\"],\n    \"compilerOptions\": {\n        \"target\": \"es2016\",\n        \"module\": \"commonjs\",\n        \"rootDir\": \"./src\",\n        \"esModuleInterop\": true,\n        \"forceConsistentCasingInFileNames\": true,\n        \"resolveJsonModule\": true,\n        \"skipLibCheck\": true,\n        \"strict\": true,\n        \"outDir\": \"./dist\"\n    }\n}\n"
  },
  {
    "path": "packages/version-info/index.d.ts",
    "content": "declare module \"@imput/version-info\" {\n    export function getCommit(): Promise<string | undefined>;\n    export function getBranch(): Promise<string | undefined>;\n    export function getRemote(): Promise<string>;\n    export function getVersion(): Promise<string>;\n}\n"
  },
  {
    "path": "packages/version-info/index.js",
    "content": "import { existsSync }  from 'node:fs';\nimport { join, parse } from 'node:path';\nimport { cwd }         from 'node:process';\nimport { readFile }    from 'node:fs/promises';\n\nconst findFile = (file) => {\n    let dir = cwd();\n\n    while (dir !== parse(dir).root) {\n        if (existsSync(join(dir, file))) {\n            return dir;\n        }\n\n        dir = join(dir, '../');\n    }\n}\n\nconst root = findFile('.git');\nconst pack = findFile('package.json');\n\nconst readGit = (filename) => {\n    if (!root) {\n        throw 'no git repository root found';\n    }\n\n    return readFile(join(root, filename), 'utf8');\n}\n\nexport const getCommit = async () => {\n    return (await readGit('.git/logs/HEAD'))\n            ?.split('\\n')\n            ?.filter(String)\n            ?.pop()\n            ?.split(' ')[1];\n}\n\nexport const getBranch = async () => {\n    if (process.env.CF_PAGES_BRANCH) {\n        return process.env.CF_PAGES_BRANCH;\n    }\n\n    if (process.env.WORKERS_CI_BRANCH) {\n        return process.env.WORKERS_CI_BRANCH;\n    }\n\n    return (await readGit('.git/HEAD'))\n            ?.replace(/^ref: refs\\/heads\\//, '')\n            ?.trim();\n}\n\nexport const getRemote = async () => {\n    let remote = (await readGit('.git/config'))\n                    ?.split('\\n')\n                    ?.find(line => line.includes('url = '))\n                    ?.split('url = ')[1];\n\n    if (remote?.startsWith('git@')) {\n        remote = remote.split(':')[1];\n    } else if (remote?.startsWith('http')) {\n        remote = new URL(remote).pathname.substring(1);\n    }\n\n    remote = remote?.replace(/\\.git$/, '');\n\n    if (!remote) {\n        throw 'could not parse remote';\n    }\n\n    return remote;\n}\n\nexport const getVersion = async () => {\n    if (!pack) {\n        throw 'no package root found';\n    }\n\n    const { version } = JSON.parse(\n        await readFile(join(pack, 'package.json'), 'utf8')\n    );\n\n    return version;\n}\n"
  },
  {
    "path": "packages/version-info/package.json",
    "content": "{\n    \"name\": \"@imput/version-info\",\n    \"version\": \"1.0.1\",\n    \"description\": \"helper package for cobalt that provides commit info & version from package file.\",\n    \"main\": \"index.js\",\n    \"types\": \"index.d.ts\",\n    \"type\": \"module\",\n    \"repository\": {\n        \"type\": \"git\",\n        \"url\": \"git+https://github.com/imputnet/cobalt.git\"\n    },\n    \"author\": \"imput\",\n    \"license\": \"AGPL-3.0\",\n    \"bugs\": {\n        \"url\": \"https://github.com/imputnet/cobalt/issues\"\n    },\n    \"homepage\": \"https://github.com/imputnet/cobalt#readme\"\n}\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n    - \"api\"\n    - \"web\"\n    - \"packages/*\"\n"
  },
  {
    "path": "web/.gitignore",
    "content": "# builds\n/build\n/.svelte-kit\n/package\n\n# vite\nvite.config.js.timestamp-*\nvite.config.ts.timestamp-*\n"
  },
  {
    "path": "web/.npmrc",
    "content": "engine-strict=true\n"
  },
  {
    "path": "web/LICENSE",
    "content": "Attribution-NonCommercial-ShareAlike 4.0 International\n\n=======================================================================\n\nCreative Commons Corporation (\"Creative Commons\") is not a law firm and\ndoes not provide legal services or legal advice. Distribution of\nCreative Commons public licenses does not create a lawyer-client or\nother relationship. Creative Commons makes its licenses and related\ninformation available on an \"as-is\" basis. Creative Commons gives no\nwarranties regarding its licenses, any material licensed under their\nterms and conditions, or any related information. Creative Commons\ndisclaims all liability for damages resulting from their use to the\nfullest extent possible.\n\nUsing Creative Commons Public Licenses\n\nCreative Commons public licenses provide a standard set of terms and\nconditions that creators and other rights holders may use to share\noriginal works of authorship and other material subject to copyright\nand certain other rights specified in the public license below. The\nfollowing considerations are for informational purposes only, are not\nexhaustive, and do not form part of our licenses.\n\n     Considerations for licensors: Our public licenses are\n     intended for use by those authorized to give the public\n     permission to use material in ways otherwise restricted by\n     copyright and certain other rights. Our licenses are\n     irrevocable. Licensors should read and understand the terms\n     and conditions of the license they choose before applying it.\n     Licensors should also secure all rights necessary before\n     applying our licenses so that the public can reuse the\n     material as expected. Licensors should clearly mark any\n     material not subject to the license. This includes other CC-\n     licensed material, or material used under an exception or\n     limitation to copyright. More considerations for licensors:\n    wiki.creativecommons.org/Considerations_for_licensors\n\n     Considerations for the public: By using one of our public\n     licenses, a licensor grants the public permission to use the\n     licensed material under specified terms and conditions. If\n     the licensor's permission is not necessary for any reason--for\n     example, because of any applicable exception or limitation to\n     copyright--then that use is not regulated by the license. Our\n     licenses grant only permissions under copyright and certain\n     other rights that a licensor has authority to grant. Use of\n     the licensed material may still be restricted for other\n     reasons, including because others have copyright or other\n     rights in the material. A licensor may make special requests,\n     such as asking that all changes be marked or described.\n     Although not required by our licenses, you are encouraged to\n     respect those requests where reasonable. More considerations\n     for the public:\n    wiki.creativecommons.org/Considerations_for_licensees\n\n=======================================================================\n\nCreative Commons Attribution-NonCommercial-ShareAlike 4.0 International\nPublic License\n\nBy exercising the Licensed Rights (defined below), You accept and agree\nto be bound by the terms and conditions of this Creative Commons\nAttribution-NonCommercial-ShareAlike 4.0 International Public License\n(\"Public License\"). To the extent this Public License may be\ninterpreted as a contract, You are granted the Licensed Rights in\nconsideration of Your acceptance of these terms and conditions, and the\nLicensor grants You such rights in consideration of benefits the\nLicensor receives from making the Licensed Material available under\nthese terms and conditions.\n\n\nSection 1 -- Definitions.\n\n  a. Adapted Material means material subject to Copyright and Similar\n     Rights that is derived from or based upon the Licensed Material\n     and in which the Licensed Material is translated, altered,\n     arranged, transformed, or otherwise modified in a manner requiring\n     permission under the Copyright and Similar Rights held by the\n     Licensor. For purposes of this Public License, where the Licensed\n     Material is a musical work, performance, or sound recording,\n     Adapted Material is always produced where the Licensed Material is\n     synched in timed relation with a moving image.\n\n  b. Adapter's License means the license You apply to Your Copyright\n     and Similar Rights in Your contributions to Adapted Material in\n     accordance with the terms and conditions of this Public License.\n\n  c. BY-NC-SA Compatible License means a license listed at\n     creativecommons.org/compatiblelicenses, approved by Creative\n     Commons as essentially the equivalent of this Public License.\n\n  d. Copyright and Similar Rights means copyright and/or similar rights\n     closely related to copyright including, without limitation,\n     performance, broadcast, sound recording, and Sui Generis Database\n     Rights, without regard to how the rights are labeled or\n     categorized. For purposes of this Public License, the rights\n     specified in Section 2(b)(1)-(2) are not Copyright and Similar\n     Rights.\n\n  e. Effective Technological Measures means those measures that, in the\n     absence of proper authority, may not be circumvented under laws\n     fulfilling obligations under Article 11 of the WIPO Copyright\n     Treaty adopted on December 20, 1996, and/or similar international\n     agreements.\n\n  f. Exceptions and Limitations means fair use, fair dealing, and/or\n     any other exception or limitation to Copyright and Similar Rights\n     that applies to Your use of the Licensed Material.\n\n  g. License Elements means the license attributes listed in the name\n     of a Creative Commons Public License. The License Elements of this\n     Public License are Attribution, NonCommercial, and ShareAlike.\n\n  h. Licensed Material means the artistic or literary work, database,\n     or other material to which the Licensor applied this Public\n     License.\n\n  i. Licensed Rights means the rights granted to You subject to the\n     terms and conditions of this Public License, which are limited to\n     all Copyright and Similar Rights that apply to Your use of the\n     Licensed Material and that the Licensor has authority to license.\n\n  j. Licensor means the individual(s) or entity(ies) granting rights\n     under this Public License.\n\n  k. NonCommercial means not primarily intended for or directed towards\n     commercial advantage or monetary compensation. For purposes of\n     this Public License, the exchange of the Licensed Material for\n     other material subject to Copyright and Similar Rights by digital\n     file-sharing or similar means is NonCommercial provided there is\n     no payment of monetary compensation in connection with the\n     exchange.\n\n  l. Share means to provide material to the public by any means or\n     process that requires permission under the Licensed Rights, such\n     as reproduction, public display, public performance, distribution,\n     dissemination, communication, or importation, and to make material\n     available to the public including in ways that members of the\n     public may access the material from a place and at a time\n     individually chosen by them.\n\n  m. Sui Generis Database Rights means rights other than copyright\n     resulting from Directive 96/9/EC of the European Parliament and of\n     the Council of 11 March 1996 on the legal protection of databases,\n     as amended and/or succeeded, as well as other essentially\n     equivalent rights anywhere in the world.\n\n  n. You means the individual or entity exercising the Licensed Rights\n     under this Public License. Your has a corresponding meaning.\n\n\nSection 2 -- Scope.\n\n  a. License grant.\n\n       1. Subject to the terms and conditions of this Public License,\n          the Licensor hereby grants You a worldwide, royalty-free,\n          non-sublicensable, non-exclusive, irrevocable license to\n          exercise the Licensed Rights in the Licensed Material to:\n\n            a. reproduce and Share the Licensed Material, in whole or\n               in part, for NonCommercial purposes only; and\n\n            b. produce, reproduce, and Share Adapted Material for\n               NonCommercial purposes only.\n\n       2. Exceptions and Limitations. For the avoidance of doubt, where\n          Exceptions and Limitations apply to Your use, this Public\n          License does not apply, and You do not need to comply with\n          its terms and conditions.\n\n       3. Term. The term of this Public License is specified in Section\n          6(a).\n\n       4. Media and formats; technical modifications allowed. The\n          Licensor authorizes You to exercise the Licensed Rights in\n          all media and formats whether now known or hereafter created,\n          and to make technical modifications necessary to do so. The\n          Licensor waives and/or agrees not to assert any right or\n          authority to forbid You from making technical modifications\n          necessary to exercise the Licensed Rights, including\n          technical modifications necessary to circumvent Effective\n          Technological Measures. For purposes of this Public License,\n          simply making modifications authorized by this Section 2(a)\n          (4) never produces Adapted Material.\n\n       5. Downstream recipients.\n\n            a. Offer from the Licensor -- Licensed Material. Every\n               recipient of the Licensed Material automatically\n               receives an offer from the Licensor to exercise the\n               Licensed Rights under the terms and conditions of this\n               Public License.\n\n            b. Additional offer from the Licensor -- Adapted Material.\n               Every recipient of Adapted Material from You\n               automatically receives an offer from the Licensor to\n               exercise the Licensed Rights in the Adapted Material\n               under the conditions of the Adapter's License You apply.\n\n            c. No downstream restrictions. You may not offer or impose\n               any additional or different terms or conditions on, or\n               apply any Effective Technological Measures to, the\n               Licensed Material if doing so restricts exercise of the\n               Licensed Rights by any recipient of the Licensed\n               Material.\n\n       6. No endorsement. Nothing in this Public License constitutes or\n          may be construed as permission to assert or imply that You\n          are, or that Your use of the Licensed Material is, connected\n          with, or sponsored, endorsed, or granted official status by,\n          the Licensor or others designated to receive attribution as\n          provided in Section 3(a)(1)(A)(i).\n\n  b. Other rights.\n\n       1. Moral rights, such as the right of integrity, are not\n          licensed under this Public License, nor are publicity,\n          privacy, and/or other similar personality rights; however, to\n          the extent possible, the Licensor waives and/or agrees not to\n          assert any such rights held by the Licensor to the limited\n          extent necessary to allow You to exercise the Licensed\n          Rights, but not otherwise.\n\n       2. Patent and trademark rights are not licensed under this\n          Public License.\n\n       3. To the extent possible, the Licensor waives any right to\n          collect royalties from You for the exercise of the Licensed\n          Rights, whether directly or through a collecting society\n          under any voluntary or waivable statutory or compulsory\n          licensing scheme. In all other cases the Licensor expressly\n          reserves any right to collect such royalties, including when\n          the Licensed Material is used other than for NonCommercial\n          purposes.\n\n\nSection 3 -- License Conditions.\n\nYour exercise of the Licensed Rights is expressly made subject to the\nfollowing conditions.\n\n  a. Attribution.\n\n       1. If You Share the Licensed Material (including in modified\n          form), You must:\n\n            a. retain the following if it is supplied by the Licensor\n               with the Licensed Material:\n\n                 i. identification of the creator(s) of the Licensed\n                    Material and any others designated to receive\n                    attribution, in any reasonable manner requested by\n                    the Licensor (including by pseudonym if\n                    designated);\n\n                ii. a copyright notice;\n\n               iii. a notice that refers to this Public License;\n\n                iv. a notice that refers to the disclaimer of\n                    warranties;\n\n                 v. a URI or hyperlink to the Licensed Material to the\n                    extent reasonably practicable;\n\n            b. indicate if You modified the Licensed Material and\n               retain an indication of any previous modifications; and\n\n            c. indicate the Licensed Material is licensed under this\n               Public License, and include the text of, or the URI or\n               hyperlink to, this Public License.\n\n       2. You may satisfy the conditions in Section 3(a)(1) in any\n          reasonable manner based on the medium, means, and context in\n          which You Share the Licensed Material. For example, it may be\n          reasonable to satisfy the conditions by providing a URI or\n          hyperlink to a resource that includes the required\n          information.\n       3. If requested by the Licensor, You must remove any of the\n          information required by Section 3(a)(1)(A) to the extent\n          reasonably practicable.\n\n  b. ShareAlike.\n\n     In addition to the conditions in Section 3(a), if You Share\n     Adapted Material You produce, the following conditions also apply.\n\n       1. The Adapter's License You apply must be a Creative Commons\n          license with the same License Elements, this version or\n          later, or a BY-NC-SA Compatible License.\n\n       2. You must include the text of, or the URI or hyperlink to, the\n          Adapter's License You apply. You may satisfy this condition\n          in any reasonable manner based on the medium, means, and\n          context in which You Share Adapted Material.\n\n       3. You may not offer or impose any additional or different terms\n          or conditions on, or apply any Effective Technological\n          Measures to, Adapted Material that restrict exercise of the\n          rights granted under the Adapter's License You apply.\n\n\nSection 4 -- Sui Generis Database Rights.\n\nWhere the Licensed Rights include Sui Generis Database Rights that\napply to Your use of the Licensed Material:\n\n  a. for the avoidance of doubt, Section 2(a)(1) grants You the right\n     to extract, reuse, reproduce, and Share all or a substantial\n     portion of the contents of the database for NonCommercial purposes\n     only;\n\n  b. if You include all or a substantial portion of the database\n     contents in a database in which You have Sui Generis Database\n     Rights, then the database in which You have Sui Generis Database\n     Rights (but not its individual contents) is Adapted Material,\n     including for purposes of Section 3(b); and\n\n  c. You must comply with the conditions in Section 3(a) if You Share\n     all or a substantial portion of the contents of the database.\n\nFor the avoidance of doubt, this Section 4 supplements and does not\nreplace Your obligations under this Public License where the Licensed\nRights include other Copyright and Similar Rights.\n\n\nSection 5 -- Disclaimer of Warranties and Limitation of Liability.\n\n  a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE\n     EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS\n     AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF\n     ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,\n     IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,\n     WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR\n     PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,\n     ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT\n     KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT\n     ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.\n\n  b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE\n     TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,\n     NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,\n     INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,\n     COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR\n     USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN\n     ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR\n     DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR\n     IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.\n\n  c. The disclaimer of warranties and limitation of liability provided\n     above shall be interpreted in a manner that, to the extent\n     possible, most closely approximates an absolute disclaimer and\n     waiver of all liability.\n\n\nSection 6 -- Term and Termination.\n\n  a. This Public License applies for the term of the Copyright and\n     Similar Rights licensed here. However, if You fail to comply with\n     this Public License, then Your rights under this Public License\n     terminate automatically.\n\n  b. Where Your right to use the Licensed Material has terminated under\n     Section 6(a), it reinstates:\n\n       1. automatically as of the date the violation is cured, provided\n          it is cured within 30 days of Your discovery of the\n          violation; or\n\n       2. upon express reinstatement by the Licensor.\n\n     For the avoidance of doubt, this Section 6(b) does not affect any\n     right the Licensor may have to seek remedies for Your violations\n     of this Public License.\n\n  c. For the avoidance of doubt, the Licensor may also offer the\n     Licensed Material under separate terms or conditions or stop\n     distributing the Licensed Material at any time; however, doing so\n     will not terminate this Public License.\n\n  d. Sections 1, 5, 6, 7, and 8 survive termination of this Public\n     License.\n\n\nSection 7 -- Other Terms and Conditions.\n\n  a. The Licensor shall not be bound by any additional or different\n     terms or conditions communicated by You unless expressly agreed.\n\n  b. Any arrangements, understandings, or agreements regarding the\n     Licensed Material not stated herein are separate from and\n     independent of the terms and conditions of this Public License.\n\n\nSection 8 -- Interpretation.\n\n  a. For the avoidance of doubt, this Public License does not, and\n     shall not be interpreted to, reduce, limit, restrict, or impose\n     conditions on any use of the Licensed Material that could lawfully\n     be made without permission under this Public License.\n\n  b. To the extent possible, if any provision of this Public License is\n     deemed unenforceable, it shall be automatically reformed to the\n     minimum extent necessary to make it enforceable. If the provision\n     cannot be reformed, it shall be severed from this Public License\n     without affecting the enforceability of the remaining terms and\n     conditions.\n\n  c. No term or condition of this Public License will be waived and no\n     failure to comply consented to unless expressly agreed to by the\n     Licensor.\n\n  d. Nothing in this Public License constitutes or may be interpreted\n     as a limitation upon, or waiver of, any privileges and immunities\n     that apply to the Licensor or You, including from the legal\n     processes of any jurisdiction or authority.\n\n=======================================================================\n\nCreative Commons is not a party to its public\nlicenses. Notwithstanding, Creative Commons may elect to apply one of\nits public licenses to material it publishes and in those instances\nwill be considered the “Licensor.” The text of the Creative Commons\npublic licenses is dedicated to the public domain under the CC0 Public\nDomain Dedication. Except for the limited purpose of indicating that\nmaterial is shared under a Creative Commons public license or as\notherwise permitted by the Creative Commons policies published at\ncreativecommons.org/policies, Creative Commons does not authorize the\nuse of the trademark \"Creative Commons\" or any other trademark or logo\nof Creative Commons without its prior written consent including,\nwithout limitation, in connection with any unauthorized modifications\nto any of its public licenses or any other arrangements,\nunderstandings, or agreements concerning use of licensed material. For\nthe avoidance of doubt, this paragraph does not form part of the\npublic licenses.\n\nCreative Commons may be contacted at creativecommons.org."
  },
  {
    "path": "web/README.md",
    "content": "# cobalt web\nthe cobalt frontend is a static web app built with\n[sveltekit](https://kit.svelte.dev/) + [vite](https://vitejs.dev/).\n\n## configuring\n- to run the dev environment, run `pnpm run dev`.\n- to make the release build of the frontend, run `pnpm run build`.\n\n## environment variables\nthe frontend has several build-time environment variables for configuring various features. to use\nthem, you must specify them when building the frontend (or running a vite server for development).\n\n`WEB_DEFAULT_API` is **required** to run cobalt frontend.\n\n| name                            | example                     | description                                                                                             |\n|:--------------------------------|:----------------------------|:--------------------------------------------------------------------------------------------------------|\n| `WEB_HOST`                      | `cobalt.tools`              | domain on which the frontend will be running. used for meta tags and configuring plausible.             |\n| `WEB_PLAUSIBLE_HOST`            | `plausible.io`*             | enables plausible analytics with provided hostname as receiver backend.                                 |\n| `WEB_DEFAULT_API`               | `https://api.cobalt.tools/` | changes url which is used for api requests by frontend clients.                                         |\n| `ENABLE_DEPRECATED_YOUTUBE_HLS` | `true`                      | enables the youtube HLS settings entry; allows sending the related variable to the processing instance. |\n\n\\* don't use plausible.io as receiver backend unless you paid for their cloud service.\n   use your own domain when hosting community edition of plausible. refer to their [docs](https://plausible.io/docs) when needed.\n\n## link prefill\nto prefill the link into the input box & start the download automatically, you can pass the URL in the `#` parameter, like this:\n```\nhttps://cobalt.tools/#https://www.youtube.com/watch?v=dQw4w9WgXcQ\n```\n\nthe link can also be URI-encoded, like this:\n```\nhttps://cobalt.tools/#https%3A//www.youtube.com/watch%3Fv=dQw4w9WgXcQ\n```\n\n## license\ncobalt web code is licensed under [CC-BY-NC-SA-4.0](LICENSE).\n\nthis license allows you to:\n- copy and redistribute the code in any medium or format, and\n- remix, transform, use and build upon the code\n\nas long as you:\n- give appropriate credit to the original repo,\n- provide a link to the license and indicate if changes to the code were made,\n- release the code under the **same license**, and\n- **don't use the code for any commercial purposes**.\n\ncobalt branding, mascots, and other related assets included in the repo are ***copyrighted*** and not covered by the license. you ***cannot*** use them under same terms.\n\nyou are allowed to host an ***unmodified*** instance of cobalt with branding for **non-commercial purposes**, but this ***does not*** give you permission to use the branding anywhere else, or make derivatives of it in any way.\n\nwhen making an alternative version of the project, please replace or remove all branding (including the name).\n\n## open source acknowledgments\n### svelte + sveltekit\nthe cobalt frontend is built using [svelte](https://svelte.dev) and [sveltekit](https://svelte.dev/docs/kit/introduction), a really efficient and badass framework, we love it a lot.\n\n### libav.js\nour remux and encode workers rely on [libav.js](https://github.com/imputnet/libav.js), which is an optimized build of ffmpeg for the browser. the ffmpeg builds are made up of many components, whose licenses can be found here: [encode](https://github.com/imputnet/libav.js/blob/main/configs/configs/encode/license.js), [remux](https://github.com/imputnet/libav.js/blob/main/configs/configs/remux/license.js).\n\nyou can [support ffmpeg here](https://ffmpeg.org/donations.html)!\n\n### fonts, icons and assets\nthe cobalt frontend uses several different fonts and icon sets.\n- [Tabler Icons](https://tabler.io/icons), released under the [MIT](https://github.com/tabler/tabler-icons?tab=MIT-1-ov-file) license.\n- [Fluent Emoji by Microsoft](https://github.com/microsoft/fluentui-emoji), released under the [MIT](https://github.com/microsoft/fluentui-emoji/blob/main/LICENSE) license.\n- [Noto Sans Mono](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/) used for the download button, is licensed under the [OFL](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/about) license.\n- [IBM Plex Mono](https://fonts.google.com/specimen/IBM+Plex+Mono/) used for all other text, is licensed under the [OFL](https://fonts.google.com/specimen/IBM+Plex+Mono/license) license.\n- and the [Redaction](https://redaction.us/) font, which is licensed under the [OFL](https://github.com/fontsource/font-files/blob/main/fonts/other/redaction-10/LICENSE) license (as well as LGPL-2.1).\n- many update banners were taken from [tenor.com](https://tenor.com/).\n\n### other packages\n- [mdsvex](https://github.com/pngwn/MDsveX) to convert the changelogs into svelte components.\n- [compare-versions](https://github.com/omichelsen/compare-versions) for sorting the changelogs.\n- [svelte-sitemap](https://github.com/bartholomej/svelte-sitemap) for generating a sitemap for the frontend.\n- [sveltekit-i18n](https://github.com/sveltekit-i18n/lib) for displaying cobalt in many different languages.\n- [vite](https://github.com/vitejs/vite) for building the frontend.\n\n...and many other packages that these packages rely on.\n"
  },
  {
    "path": "web/changelogs/10.0.md",
    "content": "---\ntitle: \"cobalt, reborn\"\ndate: \"9 Sept, 2024\"\nbanner:\n    file: \"cobalt10.webp\"\n    alt: \"meowth plush staring into a screen with cobalt 10 ui shown.\"\n---\n\neverything is new! this update marks the start of the latest chapter for cobalt. we spent the entire summer working hard to deliver the best experience ever, and we really hope you enjoy the rebirth of cobalt.\n\nbefore we list the new features, we also added support for a couple of new services: facebook, bluesky, loom, and snapchat.\n\ncobalt already supports downloading videos from bluesky and will continue to do so after they're officially released. we also added support for saving images from all supported services (such as twitter).\n\nnow, here's the gist of what's new in the web app:\n\n- the web app was rebuilt from the ground up with usability & accessibility in mind. everyone will enjoy using the new web app, even if they rely on screen readers or other accessibility tools.\n- [on-device remuxing (beta)](/remux). this feature solves all compatibility issues with old software. just drop the file into the remux page, and it'll fix it up to make it work with your favorite apps! vegas pro, logic pro, fl studio, you name it.\n- [all-new settings page](/settings). settings are now way easier to understand and use. everything is appropriately labeled, categorized, and described.\n- [custom audio bitrate](/settings/audio#audio-bitrate). you can now choose what bitrate to use when processing audio.\n- [community instances (beta)](/settings/instances#community), right in the main web app. just go to instance settings and use a custom processing server if you wish. make sure to read the important safety message.\n- [tunnel all files (beta)](/settings/privacy#tunnel). this feature will hide your ip address, browser info, and bypass local network restrictions. all downloaded files will also have pretty filenames.\n- new localization system. this allowed us to implement a [language picker](/settings/appearance#language) right into cobalt. expect more languages in the future!\n- more granular error messages with proper context. no more grouped errors such as \"this happened or this or that idk lol guess\".\n- [settings data management](/settings/advanced#data). you can now export, import, or wipe settings.\n- [new donate page](/donate). the donation page has been completely reimagined, with more ways to support us than ever (via stripe and liberapay), and via sharing cobalt with a friend.\n- [comprehensive about page](/about). the new about page includes more info than ever before, and we will be progressively adding more content there to make sure there is no more confusion, period.\n- convenient updates page (*you're here*). the new updates page is comfortable to read and navigate. we have also moved to using markdown so that we can do *this* and **this** and ~~that~~. it also includes more changelogs than ever before.\n- tab key navigation across the entire web app.\n- new navigation system with proper routing, history, and all other benefits. it is now persistent and always stays on the screen.\n- new dialog system. dialogs now use native html elements, meaning that they work as you'd expect. no more finnicky navigation.\n- new picker dialog. pretty, easy to use, and works beautifully on all devices.\n\n...and this is just the tip of the iceberg. we couldn't possibly list all changes. just go and take a look around, don't be scared to press all buttons you see.\n\nand for nerds, we have a giant list of backend changes (that we are also excited about):\n- completely restructured API schema and endpoints.\n- API now has error codes instead of messages that used to contain HTML, and the error responses also include a separated error context that is much easier to parse.\n- server info endpoint returns a lot more contextual information about each instance.\n- API and web codebases have been completely separated.\n- support for OAuth2 tokens for youtube.\n- implemented JWT sessions, which are generated based on a Turnstile challenge or in the future by an API key.\n- streams are now tunnels.\n- API now returns the filename in the response instead of just as content-disposition for tunnels.\n- range requests are now supported for direct tunnels, which means these requests are also pauseable and resumable.\n- a ton of refactoring in continuous effort to make the codebase readable for everyone.\n\nthis update allows us to actually innovate and develop new & exciting features. we are no longer held back by the legacy codebase. first feature of such kind is on-device remuxing. go check it out!\n\noh yeah, we now have over 2 million monthly users. kind of insane.\n\nwe hope you enjoy this update as much as we enjoyed making it. it was a really fun summer project for both of us.\n\nhave a lovely day :D\n\n~ your friends at imput\n"
  },
  {
    "path": "web/changelogs/10.1.md",
    "content": "---\ntitle: \"squashing bugs, improving security and ux\"\ndate: \"1 Oct, 2024\"\nbanner:\n    file: \"meowth101hammer.webp\"\n    alt: \"meowth plush getting squished with a hammer.\"\n---\n\nthis update enhances the cobalt experience all around, here's everything that we added or changed since 10.0:\n\n### saving improvements:\n- youtube videos encoded in av1 are now downloaded in the webm container. they also include opus audio for the best quality all around.\n- fixed various bugs related to the download process on older devices/browsers. cobalt should work everywhere within sane limits.\n- fixed downloading of twitch clips.\n- fixed a bug where cobalt wouldn't download bluesky videos that are in a post with a quote.\n- fixed a bug that caused some youtube music videos to fail to download due to differently formatted metadata.\n- cobalt will no longer unexpectedly open video files on iOS. instead, a dialog with other options will be shown. this had to be done due to missing \"download\" button in safari's video player. you can override this by enabling [forced tunneling](/settings/privacy#tunnel).\n- fixed a bug in filename generation where certain information was added to the filename even if cobalt didn't have it (such as youtube video format).\n\n### general ui/ux improvements:\n- added a button to quickly copy a link to the section in settings or about page.\n- added `(remux)` to filenames of remuxed videos to distinguish them from the original file.\n- improved the look & behavior of the sidebar.\n- fixed cursor appearance to update correctly when using the sidebar or subpage navigation.\n- added a stepped scroller to the donation options card [on the donate page](/donate).\n- tweaked the [donate page](/donate) layout to be cleaner and more flexible.\n- fixed tab navigation for donation option buttons.\n- updated the [10.0 changelog banner](/updates#10.0) to be less boring.\n- fixed a bug that caused some changelog dates to be displayed a day later than intended.\n- changelog banner can now be saved with a right click.\n- cobalt version now gently fades in on the [settings page](/settings).\n- fixed the position of the notch easter egg on iPhone XR, 11, 16 Pro, and 16 Pro Max.\n- cobalt will let you paste the link even if the anti-bot check isn't completed yet. if anything goes wrong regarding anti-bot checks, cobalt will let you know.\n- fixed a bunch of typos and minor grammatical errors.\n- other minor changes.\n\n### about page improvements:\n- added motivation section to the [general about page](/about/general).\n- added a list of beta testers to the [credits page](/about/credits).\n- rephrased some about sections to improve clarity and readability.\n- made about page body narrower to be easier to read.\n- added extra padding between sections on about page to increase readability.\n\n### internal improvements:\n- cobalt now preloads server info for quicker access to supported services & loading turnstile on demand.\n- converted all elements and the about page to be translatable in preparations for community-sourced translations *(coming soon!)*.\n- added `content-security-policy` header to restrict and better prevent XSS attacks.\n- moved the turnstile bot check key to the server, making it load the script on the client only if necessary.\n- fixed a bug in the api that allowed for making requests without a valid `Accept` header if authentication wasn't enabled on an instance.\n\nyou can also check [all commits since the 10.0 release on github](https://github.com/imputnet/cobalt/compare/08bc5022...f461b02f).\n\nwe hope you enjoy this stable update and have a wonderful day!\n\n\\~ your friends at imput ❤️\n"
  },
  {
    "path": "web/changelogs/10.3.md",
    "content": "---\ntitle: \"fastest cobalt yet, new youtube features, translation platform, and a lot more\"\ndate: \"4 Nov, 2024\"\nbanner:\n    file: \"meowbalt_very_fast.webp\"\n    alt: \"meowbalt absolutely zooming through space and time (only meowbalt and his speed trail are pictured).\"\n---\n\n## oh-so-fast\nstarting from this update, cobalt can run several instances in parallel, reducing load on individual instances and making it much faster.\npreviously cobalt ran on *only one thread*, and it's honestly impressive that it lasted this long.\n\nwe tested cobalt under peak traffic load & same network conditions:\n- initial request processing is now **~14 times faster than before**.\n- starting a tunnel is now **~32 times faster**.\n\n<div style=\"display: flex; justify-content: center;\">\n\n|                   | 10.2    | **10.3**   |\n|-------------------|---------|------------|\n| processing        | 14780ms | **1070ms** |\n| starting a tunnel | 11660ms | **360ms**  |\n\n</div>\n\nthese tests weren't really scientific as we based them on screen recordings,\nbut the point still stands: cobalt no longer slows down and runs as fast as it can.\n\n## youtube improvements\n- added a [new hls option](/settings/video#youtube-hls) that allows for downloading *more formats* of youtube videos.\n- fixed an issue that caused long youtube videos to get abruptly cut off. if you still experience this issue, try enabling the [new hls option](/settings/video#youtube-hls) in settings!\n- added an option to [pick any audio track language](/settings/audio#youtube-dub) for youtube videos in settings. all languages that youtube supports are listed, cobalt will fall back to default if preferred language isn't available.\n- if a [youtube codec](/settings/video#youtube-codec) isn't available, cobalt will now fall back to the next best one.\n\n## meet weblate, a place where you can translate cobalt\nwe're finally ready to invite you to translate cobalt to any language you like! your translation contributions are linked to your github account, so you'll show up in cobalt's contributors list.\n\nyou can start translating cobalt at [i18n.imput.net](https://i18n.imput.net/) right now!\n\nthank you for showing such an overwhelming amount of interest in making cobalt more accessible around the world, we really appreciate it!\n\n## other service improvements\n- added support for bookmark links from twitter.\n- fixed parsing of some mobile tiktok links.\n- fixed twitter gifs having an incorrect extension in the content picker.\n- fixed a bug that broke downloading older (shorter) links from streamable.\n- fixed video downloading from odnoklassniki (ok.ru).\n\n## ui/ux improvements\n- [always-on file tunneling](/settings/privacy#tunnel) is out of beta! feel free to use it if your isp tracks or filters your internet traffic.\n- redesigned the [community & support page](/about/community), added bluesky and removed support email.\n- improved the debug page: added a button to copy data, added current states, fixed padding. if you're curious, it can be enabled in [advanced settings](/settings/advanced#debug).\n- reduced timeouts on action buttons in security warning popups as they were very annoying before.\n- added a message about cobalt not being fully usable without javascript when the page is loaded without it.\n- improved contrast of all emoji icons on the home/save page.\n- improved contrast of the toggle button.\n- fixed the color of text selection, it's no longer hideous.\n- audio bitrate section now gets greyed out when it's not applicable.\n- fixed cursor state (pointer, arrow, etc) on various buttons.\n- fixed a bug when iphone landscape mode optimizations were applied incorrectly (fix for a bug in ios firefox).\n- various text/phrasing improvements across ui.\n- small padding improvements across ui.\n- other small improvements.\n\n## documentation improvements\n- all [documentation on github](https://github.com/imputnet/cobalt) was majorly improved. all projects and docs are now listed in the main readme. all docs are now easier to read and follow.\n- added a new document outlining all [instance protection methods](https://github.com/imputnet/cobalt/blob/main/docs/protect-an-instance.md) along with step-by-step tutorials on how to configure them.\n- added a tutorial for [configuring a cobalt instance for youtube downloading](https://github.com/imputnet/cobalt/blob/main/docs/configure-for-youtube.md).\n- updated [contribution guidelines](https://github.com/imputnet/cobalt/blob/main/CONTRIBUTING.md).\n- updated [examples](https://github.com/imputnet/cobalt/tree/main/docs/examples) for cookie & docker compose files. we now recommend running cobalt api as **read only** image, as it ensures that it wasn't tampered with. we do it on our servers, too.\n\n## internal improvements for nerds\n- added support for api keys, api instance hosters are now able to limit access to a set of people. you can see [how to configure them on github](https://github.com/imputnet/cobalt/blob/main/docs/protect-an-instance.md#configure-api-keys).\n- cobalt api docker image is now running alpine & node 23. it's also much smaller than before.\n- instances now log whether they were able to load cookies or api keys. no more guessing if your config works or not.\n- updated the console error when cobalt api is configured incorrectly.\n- majorly refactored the youtube module.\n- lots of general api code refactoring.\n- improved settings schema migration on frontend.\n- removed outdated api functions, util scripts, and docs.\n\n## fixed a XSS vulnerability that wasn't exploited\na malicious cobalt instance could serve links with the javascript: protocol, resulting in XSS when the user tries to download an item from a picker.\n\nas far as we know, this vulnerability was never found and exploited in the wild, but we still urge all frontend instance hosters to **update their instances asap**. cobalt.tools and all other instances that configured CSP correctly weren't affected by this vulnerability.\n\nthis issue was fully fixed in [c4be1d3](https://github.com/imputnet/cobalt/commit/c4be1d3a37b0deb6b6087ec7a815262ac942daf1) and [an advisory with CVE was posted on github](https://github.com/imputnet/cobalt/security/advisories/GHSA-cm4c-v4cm-3735).\n\nif you ever discover a security vulnerability in cobalt, please report it responsibly [on github](https://github.com/imputnet/cobalt/security/advisories/new). we'll make sure to fix it as soon as possible!\n\n## where's 10.2?\nwe were very excited to release the first part of changes, so we bumped the version early. then, we decided to make cobalt faster, so now we're at 10.3!\n\n*we also silently released changes in prod before the announcement, teehee :3c*\n\n## all changes are on github\nas always, you can check [all commits since the 10.1 release on github](https://github.com/imputnet/cobalt/compare/f461b02...c021293) for even more details.\n\nwe hope you enjoy this update as much as you enjoy fresh air, because it really feels like one!\n\n\\~ your friends at imput ❤️"
  },
  {
    "path": "web/changelogs/10.5.md",
    "content": "---\ntitle: \"merry christmas and happy new year!\"\ndate: \"23 Dec, 2024\"\nbanner:\n    file: \"newyear2025.webp\"\n    alt: \"meowth plush in a christmas hat sitting in front of a shiny christmas tree.\"\n---\n\n## where the elves at?\nwe are back once again with another cobalt update, whether you like it or not! just like santa, we come when you least expect us.\n\nwe're back to the battlefield against youtube's scraper flattener, but we're winning so far! we even managed to squeeze in a ton of improvements that range from performance bumps to ui overhauls to brand new features. make sure to read further or you might end up on the naughty list...\n\n## even more youtube improvements\n- countless infrastructure improvements and developments that allowed us to keep youtube support available during the worst times.\n- improved youtube codec fallback. now cobalt goes through all codecs to find you the best one!\n- improved youtube video quality selection & fallback.\n\n## improvements for other services\n- added support for loom's video embed links.\n- added support for facebook's mobile subdomain links.\n- fixed a bug in the instagram module where it wouldn't use the graphql api on failure, due to which cobalt was unable to load slightly more posts successfully. now the majority of posts are accessible!\n- removed support for vine because &#120143;, \"The Everything App\", broke the vine archive.\n- increased performance of downloads from bluesky by using the video cdn directly.\n- error messages from bluesky module are now more descriptive.\n- rewrote the vk video extraction module to use the general api as the web app extraction was broken by a vk update.\n- added support for new vk video links.\n- cobalt now shows an appropriate error if:\n    - soundcloud track is region locked or paywalled.\n    - tiktok post is age restricted or otherwise unavailable.\n    - rutube video is region locked.\n    - vk video is region locked.\n\n## web app (and ui/ux) improvements\n- added support for [instance access keys](/settings/instances#access-key)! now you can access private cobalt instances with no turnstile, directly from the web app.\n- redesigned the [remux page](/remux) to indicate better what remuxing does and what it's for.\n- majorly improved the reliability of turnstile. it no longer gets stuck in the background, and cobalt always keeps track of its state and displays it in the omnibox.\n- rewrote almost all error messages in an effort to make them easier to understand at a glance.\n- added more error messages to describe processing issues even better whenever possible.\n- added animations to omnibox icons that make them more lively and cute.\n- improved the toggle animation, made it stretchy and jumpy just like the rest of the ui.\n- made the cobalt web app fully compatible with RTL languages (such as arabic).\n- added an automatically generated sitemap, making the web app easier to index by search engine crawlers.\n- made it way easier to override the selfhosted processing instance in a selfhosted web app.\n- removed an extra security warning in the selfhosted web app which appeared when the processing instance didn't match the default one.\n- added the \"community instance\" label to the web app that appears on instances different from the official one, making it easier to differentiate them from one another.\n- updated cobalt embed description to be less corny.\n- fixed a bug that caused settings to be exported improperly on ios in PWA mode. now they're extracted via the share api, just like all other files!\n- fixed the weird focus borders in chromium browsers that appeared after a recent browser update.\n- optimized rendering of the _supported services_ popover & updated its animation.\n- improved accessibility of the web app all around.\n- other tiny but mighty changes.\n\n*~ 🦆🔜 ~*\n\n## processing instance improvements\n- added support for one more way of youtube authentication: web cookies with poToken & visitorData, so that everyone can access youtube on their instances again!\n- significantly refactored the cookie system for better error resistance.\n- added success and error console messages to indicate whether cookies/keys were loaded successfully.\n- cobalt now warns if it was unable to save updated cookies back to the file.\n- majorly refactored the youtube module and removed unnecessary extra loops.\n- cobalt no longer loads unnecessary data from youtube when not needed.\n- fixed a bug where cobalt tried to proxy URLs on local network when global proxy was configured.\n- fixed a bug that caused some HLS videos to be impossible to download in the \"mute\" download mode.\n- fixed a bug where cobalt stacked HLS streams several times within itself which caused heavily reduced performance.\n- fixed a bug where cobalt did not use a dispatcher on a HLS stream's chunks, sometimes causing it to access content from an incorrect IP address.\n- refactored automatic testing CI, made service tests easier to manage.\n- reduced docker container privileges to a regular user.\n- improved rich filename & metadata support. all metadata is now added to the file \"as-is\" with no modifications at all. filenames are now compatible with all operating systems and files should never appear as \"tunnel\", even in some rare cases.\n\n## more details\nas always, you can check [all commits since the last release on github](https://github.com/imputnet/cobalt/compare/c021293...41430ff) for *literally all changes!*\n\n## thank you!\nour [github repo](https://github.com/imputnet/cobalt) reached over 20k stars recently, and around the same time the cobalt web app reached over 150k daily visitors. both of these numbers are insane to think about, thank you so much for your support!\n\nthis is the last big update of 2024, the most transformative and exciting year for cobalt yet.\nwe're already working on new cool features that'll come out next year :3\n\nwe hope you have amazing holidays and 2025!\n\n\\~ your jolly friends at imput 🎄\n\n## donate to imput\nplz [donate](/donate), we as elves work all day and night\n\n![sad hampter in a christmas hat](data:image/webp;base64,UklGRn4BAABXRUJQVlA4IHIBAAAwDgCdASpAAEkAP1Waw1oxqqckKbqq2jAqiWIA0kkRgW9ViVdiWdXKQi6/gdi6yh7EP2hdKybn20T+5U2HORdT1INF/azUgAe83P37UIt7DaMNjNpN1q36xYwYmvqRvyHZCbmjuEi8jMI5QwpK+A6PL5WzAeMK1HHSwAD+6mC6qPoWsYNuVCVokfhT4iULSdrgIUxMVYuFmvaB6EO1tiQsDKgGz3TT/evh4KRuHM3hK23nOULaAYPQUKFqt6mmdlUXEnnkybyuQspqBd7vYu7KCfAgexNvxKgitS1o+4JfpkOuihhRfUFRqB2Z63FsbgywZxKR9zkIWWPVYn5XIBJX6LS+AU0fc7hHnV7I0boYFlIgvVJQX0k1Tcuvk4aS9UnxcZXhLIrob7G+vHgUt4z1jVbRN+cMa/ymg+mH2qtsTW1QyhZqaerV930ZZFSsPbSlxCabNU46cRYJ3EIYwxRS6n16lWtc1hKg3Tk23rG9AAAA)\n![one more sad hampter in a christmas hat](data:image/webp;base64,UklGRn4BAABXRUJQVlA4IHIBAAAwDgCdASpAAEkAP1Waw1oxqqckKbqq2jAqiWIA0kkRgW9ViVdiWdXKQi6/gdi6yh7EP2hdKybn20T+5U2HORdT1INF/azUgAe83P37UIt7DaMNjNpN1q36xYwYmvqRvyHZCbmjuEi8jMI5QwpK+A6PL5WzAeMK1HHSwAD+6mC6qPoWsYNuVCVokfhT4iULSdrgIUxMVYuFmvaB6EO1tiQsDKgGz3TT/evh4KRuHM3hK23nOULaAYPQUKFqt6mmdlUXEnnkybyuQspqBd7vYu7KCfAgexNvxKgitS1o+4JfpkOuihhRfUFRqB2Z63FsbgywZxKR9zkIWWPVYn5XIBJX6LS+AU0fc7hHnV7I0boYFlIgvVJQX0k1Tcuvk4aS9UnxcZXhLIrob7G+vHgUt4z1jVbRN+cMa/ymg+mH2qtsTW1QyhZqaerV930ZZFSsPbSlxCabNU46cRYJ3EIYwxRS6n16lWtc1hKg3Tk23rG9AAAA)\n"
  },
  {
    "path": "web/changelogs/11.0.md",
    "content": "---\ntitle: \"local media processing, better performance, and a lot of polish\"\ndate: \"29 May, 2025\"\nbanner:\n    file: \"meowth_beach.webp\"\n    alt: \"meowth plush with obnoxious sunglasses on foreground, very close to the camera. sunset and beach in background.\"\n---\n\nlong time no see! it's almost summer, the perfect time to create or discover something new. we've been busy working in the background to make cobalt better than ever, but now we're finally ready to share the new major version.\n\nas a part of the major update, we revised our [terms of use](/about/terms) & [privacy policy](/about/privacy) to reflect new privacy-enhancing features & to improve readability; you can compare what exactly changed in [this commit](https://github.com/imputnet/cobalt/commit/be84f66) on github. **nothing changed about our principles or dedication to privacy**, but we still thought it'd be good to let you know.\n\nhere are the highlights of what's new in cobalt 11 and what else has changed since the last changelog in december:\n\n## on-device media processing (beta)\ncobalt can now perform all media processing tasks *directly in your browser*. we enabled it by default on all desktop browsers & firefox on android, but if you want to try it on your device before we're sure it works the way we expect, you can do it in a new [local processing page in settings](/settings/local)!\n\nhere's what it means for you:\n- **best file compatibility**, because all processed files now have proper headers. there's **no need to remux anything manually** anymore; all editing software should support cobalt files right away! vegas pro, logic pro, many DAWs, windows media player, whatsapp, etc, — all of them now support files from cobalt *(but only if they were processed on-device)*.\n- **detailed progress** of file processing. cobalt now displays all steps and the current progress of all of them. no more guessing when's the file gonna be ready.\n- **faster processing** for all tasks that require remuxing or transcoding, such as downloading youtube videos, transcoding audio, muting videos, or converting gifs from twitter.\n- **better reliability** of all processing tasks. cobalt can finally catch all processing errors properly, meaning that the corrupted file rate will drop significantly. if anything ever goes wrong, cobalt will let you know, and you'll be able to retry right away!\n- **reduced load on public instances**, which makes cobalt faster than ever for everyone. servers will no longer be busy transcoding someone's 10 hour audio of \"beats to vibe and study to\" — because now their own device is responsible for this work. it's really cool!\n\nwe're also introducing the processing queue, which allows you to schedule many tasks at once! it's always present on the screen, in the top right corner. the button for it displays precise progress across all tasks, so you know when tasks are done at a glance.\n\nall processed videos are temporarily stored on your device and are automatically wiped when you reload the page. no need to rush saving them; they'll be there as long as you don't close cobalt or delete them from the queue.\n\non modern ios (18.0+), we made it possible to download, process, and export giant files. the limit is now your device's storage, so go wild!\n\nprocessing queue & local processing may not be perfect as-is, so please let us know about any frustrations you face when using them! this is just the beginning of an on-device era of cobalt. we hope to explore even more cool local processing features in the future.\n\n## web app improvements: ui/ux upgrade and svelte 5\naside from local processing, we put in a ton of effort to make the cobalt web app faster and even more comfortable for everyone. we fixed all minor ui nicks, polished it, and improved turnstile behavior!\n\n- **svelte 5:** many parts of cobalt's frontend have been migrated to svelte 5. this is mostly an internal change, but it majorly improves the performance and reduces extra ui renders, making the overall experience snappier.\n- **downloading flow:**\n    - cobalt will now start the downloading task right away, even if turnstile is not finished verifying the browser yet. it will wait for turnstile's solution instead of showing an annoying dialog.\n    - pressing \"paste\" before turnstile is finished now starts the download right away.\n    - prefilled links via url parameters (`#urlhere` or `?u=urlhere`) are now downloaded right away. one less button press!\n    - replaced an invasive turnstile dialog with a dynamic tooltip.\n    - ideally, you should no longer know that cloudflare turnstile is even there.\n- **remux**:\n    - remux is now a part of the processing queue! the remux page now serves as an importer. no need to stay on the same page for remux to complete.\n    - you can now remux several files at once.\n    - cobalt now automatically filters out unsupported files on import, so you can drag and drop whatever.\n- **visuals & animations:**\n    - the dialog animation & visual effects have been optimized to improve performance. the picker dialog no longer lags like hell!\n    - all images now fade in smoothly on load.\n    - the update notification now has a new, springier animation.\n    - enhanced focus rings across the whole app for better accessibility, a cleaner look, and ease of internal maintenance.\n    - sidebar is now bright in light theme mode on desktop, and is more visible in dark mode.\n    - sidebar buttons are now more compact.\n    - the status bar color on desktop (primarily safari) now adapts to the current theme.\n    - fixed many various rendering quirks in webkit and blink.\n    - all input bars are now pressable everywhere.\n    - popovers (such as supported services & queue) are now rendered only when needed.\n    - the font on the about/changelog pages is now consistent with the rest of the ui (IBM Plex Mono).\n    - all image assets have been re-compressed for even faster loading.\n    - the download button now uses a super tiny custom font instead of a full noto sans mono font.\n    - countless padding, margin, and alignment tweaks for overall consistency and a fresh vibe.\n- **accessibility & usability:**\n    - created a dedicated [accessibility settings page](/settings/accessibility) and moved relevant settings there.\n    - improved screen reader accessibility & tab navigation across ui.\n    - added an option to prevent the processing queue from opening automatically in [accessibility settings](/settings/accessibility#behavior).\n    - files now save properly on desktop in pwa mode (when using local processing).\n- **ios-specific improvements**:\n    - added haptic feedback to toggles, switchers, buttons, dropdowns, and error dialogs. not a fan of haptics? disable them in [accessibility settings](/settings/accessibility/haptics).\n    - made it possible to process giant files without crashing on ios 18.0+. the cobalt tab/pwa no longer crashes if a file is too big for safari to handle. *(previously anything >384mb lol)*\n    - improved file saving, now cobalt selects the most comfortable way to save a file automatically.\n- **settings page:**\n    - sensitive inputs (like api keys) are now hidden by default with an option to reveal them.\n    - added an option to hide the remux tab on mobile devices in [appearance settings](/settings/appearance#navigation).\n    - filename previews in settings now more accurately reflect the actual output.\n    - improved the toggle animation.\n    - redesigned settings page icons.\n    - updated some descriptions to be more accurate.\n- all [about](/about) pages have been revised for improved readability and clarity.\n- the web instance now requires the `WEB_DEFAULT_API` env variable to run. it's enforced to avoid any confusion.\n- the plausible script is no longer loaded when anonymous analytics are disabled.\n\n## general improvements\n- filenames can now include a wider range of characters, thanks to relaxed sanitization & use of fullwidth replacements.\n- \"basic\" is now the default filename style.\n\n## processing instance improvements\n- env variables can now be loaded & updated dynamically. this allows for configuration changes without downtime!\n- tunnels now provide an `Estimated-Content-Length` header when exact file size isn't available.\n- many internal tunnel improvements.\n- the api now returns a `429` http status code when rate limits are hit.\n- the `allowH265` (formerly `tiktokH265`) and `convertGif` (formerly `twitterGif`) api parameters have been renamed for clarity as they apply (or will apply) to more services.\n- added a bunch of new [api instance environment variables](https://github.com/imputnet/cobalt/blob/main/docs/api-env-variables.md): `FORCE_LOCAL_PROCESSING`, `API_ENV_FILE`, `SESSION_RATELIMIT_WINDOW`, `SESSION_RATELIMIT_MAX`, `TUNNEL_RATELIMIT_WINDOW`, `TUNNEL_RATELIMIT_MAX`, `CUSTOM_INNERTUBE_CLIENT`, `YOUTUBE_SESSION_SERVER`, `YOUTUBE_SESSION_INNERTUBE_CLIENT`, `YOUTUBE_ALLOW_BETTER_AUDIO`.\n\n## youtube improvements\n- added a new option in [audio settings](/settings/audio#youtube-better-audio) to prefer better audio quality from youtube when available.\n- near infinite amount of changes and improvements on cobalt & infrastructure levels to improve reliability and recover youtube functionality.\n- cobalt now returns a more appropriate error message if a youtube video is locked behind drm.\n- added itunnel transplating to allow in-place tunnel resume after an [intentional] error from origin's side.\n\n## other service improvements\n- added support for **xiaohongshu**.\n- **twitter:**\n    - added support for saving media from ad cards.\n    - added fallback to the syndication api for better reliability due to constant twitter downtimes & lockdowns.\n- **reddit:**\n    - expanded support for various link types, including mobile (e.g., `m.reddit.com`) and many other short link formats.\n- **instagram:**\n    - added support for more links, including the new `share` format.\n    - implemented more specific errors for age-restricted and private content.\n    - fixed an issue where posts might have not correctly fallen back to a photo if a video URL was missing.\n- **tiktok:**\n    - added support for tiktok lite urls.\n    - fixed parsing of some mobile tiktok links.\n    - updated the primary tiktok domain used by the api due to previous dns issues.\n- **snapchat:**\n    - fixed an issue where story extraction could fail if certain profile parameters were missing.\n    - added support for new link patterns.\n- **pinterest:**\n    - fixed video parsing for certain types of pins.\n- **bluesky:**\n    - added support for downloading tenor gifs from bluesky posts.\n- **odnoklassniki (ok):**\n    - fixed an issue where author information wasn't handled properly.\n- **loom:**\n    - added support for links with video titles.\n    - fixed support for more video types.\n- **facebook:**\n    - fixed issues caused by rate limiting.\n\n## documentation improvements\n- created a new document for [api instance environment variables](https://github.com/imputnet/cobalt/blob/main/docs/api-env-variables.md) with detailed & up-to-date info about each variable.\n- rewrote [api docs](https://github.com/imputnet/cobalt/blob/main/docs/api.md) to be easier to read and added all new & previously missing info.\n- updated the list of dependencies & open-source shoutouts in [api](https://github.com/imputnet/cobalt/blob/main/api/README.md) & [web](https://github.com/imputnet/cobalt/blob/main/web/README.md) readme docs.\n- added an example for setting up `yt-session-generator` in the docker compose documentation.\n- updated the \"run an instance\" guide with a more prominent note about abuse prevention.\n\n## more internal improvements\n- introduced an abstract storage class and implemented opfs (origin private file system) and memory storage backends. this is the foundation of the new local processing features, and makes it possible to operate on devices with low RAM.\n- session tokens are now bound to ip hashes, locking down their usage and improving security.\n- lots of other refactoring and code cleanups across both api and web components.\n- numerous test fixes, additions, and ci pipeline improvements.\n- removed unused packages & updated many dependencies.\n\n## all changes are on github\nlike always, you can check [all commits since the 10.5 release on github](https://github.com/imputnet/cobalt/compare/41430ff...a52dde7) for even more details, if you're curious.\n\nthis update was made with a lot of love and care, so we hope you enjoy it as much as we enjoyed making it.\n\nthat's all for now, we wish you an amazing summer!\n\n\\~ your friends at imput ❤️"
  },
  {
    "path": "web/changelogs/11.2.md",
    "content": "---\ntitle: \"local processing for everyone, subtitles, audio covers, and more\"\ndate: \"30 June, 2025\"\nbanner:\n    file: \"meowth_sunrise.webp\"\n    alt: \"meowth plush in a forest looking at the rising sun between the trees.\"\n---\n\nit's summertime! even though it's been rainy for us lately, the sun is right on the horizon, just like this cobalt update. we improved local processing, added long-awaited features, and improved a ton of other stuff.\n\nhere's what's new since 11.0:\n\n## on-device media processing\nlocal processing is now enabled for everyone by default! it allows for faster downloading, file consistency, and best media compatibility. in this update, we optimized it to work on older browsers, just so no one's missing out on cobalt due to having outdated software.\n\nthanks to local processing, we were able to add **audio covers** in this update. cobalt will automatically add covers/thumbnails from youtube or soundcloud, and it'll be cropped to a square when needed. really cool stuff, and it just works!\n\nplease let us know if local processing doesn't work properly on your device, we'll try to improve it!\n\n## video subtitles\nwe added support for downloading videos with subtitles! in this update, we added full support for subtitles from: `youtube`, `twitter`, `tiktok`, `vimeo`, `loom`, `vk video`, and `rutube`. we'll keep adding support for more services in the future!\n\nto download subtitles, just pick your preferred language in [metadata settings](/settings/metadata#subtitles)! cobalt will add subtitles in this language if they're available.\n\npro-tip: if you don't need audio, you can save a bit of storage by switching to the \"mute\" mode on the home page. you'll get a mute video with subtitles and the rest of the metadata!\n\ndon't want metadata or subtitles? just [disable metadata](/settings/metadata#metadata) in settings, and cobalt won't add anything.\n\n## youtube downloading\ndownloading from youtube on the main instance is restored! sorry that it took a bit over a week; we were trying our best to speed it up.\n\nhopefully it'll last for a while, but we think downloading from youtube will get significantly more annoying/complex in next few weeks-months. **right now is the best time to download everything you've been putting off**, either with cobalt or other tools.\n\n**update**: unfortunately it did not last, youtube is unavailable on the main instance again. we will try one more way soon and update this changelog and post about it on socials accordingly.\n\nwe're not trying to scare you; it's our educated guess based on what youtube has been doing lately:\n- roll out of SABR & related limitations for more clients. SABR is Server ABR, Google's proprietary HLS alternative, controlled by the server.\n- growing potoken enforcement.\n- various other experiments to restrict \"unauthorized access\".\n\nwe currently have no exact plan on how to handle SABR in cobalt, but we will try to figure it out. for now, we're using youtube clients that don't have it enforced, but we have no clue for how long this will last.\n\nby the way, we also made it possible to [choose any preferred media container](/settings/video#youtube-container) independently from the youtube video codec. could be useful for this occasion!\n\n## general service improvements\n- added more metadata to audio files from soundcloud.\n- added support for `/groups/` vimeo links.\n- added support for ``/v/:id` youtube links.\n- added support for new share links from tiktok.\n- added support for more vk video links.\n- pinterest now returns an appropriate error when a pin is unavailable.\n- AI dubs on youtube are no longer accidentally selected as default tracks.\n- youtube HLS preference is now deprecated, but can be enabled on a self-hosted instance via `ENABLE_DEPRECATED_YOUTUBE_HLS` in env.\n- downloads from vk are now way faster.\n\n## web app improvements\n- improved compatibility of local processing & related code with older browsers.\n- disabled multithreading in old mobile safari, making it possible to use local processing on iOS 15+.\n- local processing workers:\n    - the fetch worker is now less sensitive to network-related errors and returns a descriptive error whenever necessary.\n    - the ffmpeg worker now returns an appropriate error when a required stream is missing.\n    - the generic crash error is now localized.\n    - added a default file icon in case cobalt can't detect the file type.\n- made frontend compatible with static cloudflare workers.\n- most used languages in [subtitle](/settings/metadata#subtitles) and [audio track](/settings/audio#youtube-dub) dropdowns are now on top.\n- default values of subtitle/audio track dropdowns are now localized.\n- translated all UI strings to russian.\n- fixed overflow in the processing queue.\n- updated a bunch of localization strings.\n- slightly updated the update notification & fixed its location in RTL layouts.\n\n## processing instance improvements\n- fixed HLS downloading from soundcloud that was accidentally broken in the 11.0 update.\n- fixed dynamic env reloading.\n- added console messages about dynamic env changes.\n- `SESSION_RATELIMIT` is now `SESSION_RATELIMIT_MAX`, but the old name remains valid until the next major update. this is a result of a typo in 11.0, sorry!\n- `localProcessing` is now `disabled | preferred | forced`, not a boolean. 11.2 accepts boolean values, but this will be removed in a future version.\n- added `subtitleLang`, which is any valid ISO 639-1 language code.\n- removed backwards compatibility with `twitterGif` and `tiktokH265`.\n- updated `local-processing` response in correlation to addition of subtitles and audio covers.\n- a lot of refactoring.\n\nfor up-to-date info about instance variables, check the docs on github:\n- [processing instance environment variables](https://github.com/imputnet/cobalt/blob/main/docs/api-env-variables.md).\n- [api documentation](https://github.com/imputnet/cobalt/blob/main/docs/api.md).\n\n## all changes are on github\nas usual, you can check [all commits since the 11.0 release on github](https://github.com/imputnet/cobalt/compare/a52dde7...main) for even more details and exact code changes.\n\nwe hope that you enjoy this update and have a great rest of your day!\n\n\\~ your friends at imput ❤️\n"
  },
  {
    "path": "web/changelogs/2.0.md",
    "content": "---\ntitle: \"everything is new!\"\ndate: \"Jun 28, 2022\"\n---\n\n- added support for: bilibili.com, youtube, youtube music, reddit, vk;\n- remade the way downloads are handled;\n- added proper website branding;\n- added settings, donations, and changelog menu;\n- added manual theme picker;\n- added format picker for youtube;\n- added quality picker for youtube and vk downloads (bilibili and twitter later);\n- improved usability;\n- upgraded the download button to be adaptive depending on current status;\n- popups are now adaptive, too;\n- better scalability;\n- took out trash;\n- moved from commonjs to ems;\n- overall revamp of backend and frontend;\n- fixed various issues that were present in older version."
  },
  {
    "path": "web/changelogs/2.2.5.md",
    "content": "---\ntitle: \"remade localization system once again\"\ndate: \"Jul 24, 2022\"\n---\n\n- new localization system: fast, dynamic, way more organized\n- localization strings are WAY more descriptive\n- it's now easier to add support for other languages (just one loc file instead of five)\n- localization now falls back to english if localized string isnt available\n- got rid of all static language selectors (probably)\n- slightly updated english and russian strings\n- miscellaneous settings items have been bundled together and moved to the bottom, cause they're used the least\n- bottom links should no longer touch the popup border on overflow\n- rearranged popup order in the rendered page\n- bumped version up to 2.2.5\n\nif you see strings that are like this: !!EXAMPLE!! or withoutspace please file an issue on github\n"
  },
  {
    "path": "web/changelogs/2.2.6.md",
    "content": "---\ntitle: \"tiktok is back!\"\ndate: \"Jul 28, 2022\"\n---\n\n- added support for tiktok (images won't work, they're only accessible through the app)\n- hopefully main input bar is now not rounded on ios, i fucking hate apple\n- if service is not supported, a correlating error will appear, not generic one\n- removed duplicates from config that are present in package json already\n- tiny bit of clean up"
  },
  {
    "path": "web/changelogs/2.2.8.md",
    "content": "---\ntitle: \"faster and more accessible\"\ndate: \"Jul 30, 2022\"\n---\n\n- spanish localization by @adrigoomy\n- cobalt should load even faster cause all loaded files are now way smaller (esbuild implementation)"
  },
  {
    "path": "web/changelogs/2.2.9.md",
    "content": "---\ntitle: \"fixes\"\ndate: \"Aug 6, 2022\"\n---\n\n- fixed neighbor quality picking for youtube videos\n- webm is now default for youtube downloads for all platforms except for ios\n- even more readme changes\n- a tiny bit of clean up\n- preparing stuff for next major update"
  },
  {
    "path": "web/changelogs/2.2.md",
    "content": "---\ntitle: \"beginning of 2.2\"\ndate: \"Jul 13, 2022\"\n---\n\n- added download popup to solve the issue with downloads on ios\n- merged big and small popups into one\n- made buttons in donation menu act like buttons\n- began to clean up localisation\n- added ability to embed repo url into localisation strings\n- moved ffmpeg args to config for more flexibility (and hopefully future changes)\n- removed error response in stream that could result in a crash\n- removed notice for ios users from about cause it's no longer relevant\n- made error popup look and act like the rest\n- a tiny bit of clean up\n- changelog is now made out of latest commit (and doesn't break)"
  },
  {
    "path": "web/changelogs/3.0.md",
    "content": "---\ntitle: \"everything what you've been waiting for. welcome to cobalt 3.0 :)\"\ndate: \"Aug 12, 2022\"\n---\n\nfollow cobalt's twitter account for polls, updates, and more: [@justusecobalt](https://twitter.com/justusecobalt)\n\nstuff that you can notice:\n\n- you can now download audio from any supported service, in any format that you set in settings (+). yes, that includes mp3, which you all have been waiting for :D\n- it's now easier to switch between download modes (just a single toggle on the bottom).\n- your youtube download format has been reset, sorry, but that was required to implement all audio downloads.\n- default download format for youtube videos on all platforms is now webm. except for ios.\n\n- cobalt now has emoji, just to spice up the black and white ui. all of them have been tuned to look the best in both themes. isn't it cool?\n- about, changelog, and donation popups have been merged into just one, for covnenience.\n- changelog got a huge upgrade (as you can see), and now there are both major changes and latest commit info, just so commits can finally go back to being batshit insane.\n- changelog popup appears on every major update, but you can disable it in settings, if you want to.\n- changelog now opens by default when pressing \"?\" button. i don't think anyone reads \"about\" as often.\n- settings (+) have been split into three tabs, also for convenience and ease of use.\n\n- added support for donation links. you can now donate through boosty, not only via crypto :D\n- donate popup has been rearranged and tuned just a tiny bit.\n\n- you can now click away from any popup by pressing the void behind it.\n- you can also press \"escape\" key on keyboard to close any popup.\n\n- switchers and buttons are now way easier on eye. white border is gone from where it's unneeded.\n- buttons are now very satisfying to press.\n- switchers are scrollable if there's not enough space to fit all contents on screen.\n- scaling is now even better than before.\n\ninternal stuff:\n\n- frontend won't send video related stuff if audio mode is on.\n- matching has, yet again, gone through mitosis, and is now probably the cleanest it can get.\n- page rendering is now modular, something like what frameworks have but way lighter. this makes adding new features WAY easier.\n- removed some stuff that didn't make sense (like storing language of stream request).\n- cleaned up insides of cobalt, of course.\n- almost all links now open in new tab, just like they should have from the very beginning.\n\nknown issues:\n- impossible to download audio from vk. i'll try to fix it in the next update.\n- headers are not sticky in tabbed popups. maybe this is a good thing, i'll think about it.\n\nif you ever notice any issues, make sure to report them on github. your report doesn't have to sound professional, just do your best to describe the issue."
  },
  {
    "path": "web/changelogs/3.1.md",
    "content": "---\ntitle: small quality of life improvements\ndate: \"Aug 16, 2022\"\n---\n\n- tiktok videos can now be downloaded without watermark, you just have to enable it in video settings (+)!\n- you now can pass \"u\" query to main website to fill out the input area right away (co.wukko.me?u=your_link_here).\n- added ability to select text in certain areas of website.\n- some internal stuff has been cleaned up.\n\nfollow cobalt's twitter account for polls, updates, and more: [@justusecobalt](https://twitter.com/justusecobalt)"
  },
  {
    "path": "web/changelogs/3.2.md",
    "content": "---\ntitle: ukrainian localization and new error popup\ndate: \"Aug 19, 2022\"\n---\n\n- added ukrainian localization (thanks to löffel).\n- new error popup! it's now prettier, more compact, and has an easily accessible close button.\n- russian localization has been patched up a bit\n- cleaned up css a bit\n- added github contributors to made with love message.\n- emojis have been tuned to have the same shade of yellow.\n- updated translation guidelines in readme a bit."
  },
  {
    "path": "web/changelogs/3.4.md",
    "content": "---\ntitle: tiktok images and better localization\ndate: \"Sep 3, 2022\"\n---\n\n- added ability to save images from tiktok conveniently, and without watermarks.\n- 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\n- updated readme in github repo to make it easier to read and understand.\n- began to add more descriptive errors, more to come soon.\n\ninternal stuff:\n- remade entirety of tiktok module and merged it with douyin one. now both (basically identical) platforms have perfect parity of download features.\n- cleaned up the twitter module, now it's way more compact and easy to read.\n- moved changelog out of english localization.\n- other small improvements and fixes."
  },
  {
    "path": "web/changelogs/3.5.2.md",
    "content": "---\ntitle: \"vk clips support, improved changelog system, and less bugs\"\ndate: \"Sep 11, 2022\"\n---\nnew features:\n- added support for vk clips. cobalt now lets you download even more cringy videos!\n- 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.\n- 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.\n\nchanges:\n- moved twitter entry to about tab and made it localized.\n- added clarity to what services exactly are supported in about tab.\n\nbug fixes:\n- cobalt should no longer crash to firefox users if they love to play around with user-agent switching.\n- vk videos of any resolution and aspect ratio should now be downloadable.\n- vk quality picking has been fixed after vk broke it for parsers on their side."
  },
  {
    "path": "web/changelogs/3.5.4.md",
    "content": "---\ntitle: \"tiktok support is back :D\"\ndate: \"Sep 21, 2022\"\n---\nyou can download videos, sounds, and images from tiktok again!\nhuge thank you to [@minzique](https://github.com/minzique) for finding another api endpoint that works."
  },
  {
    "path": "web/changelogs/3.5.md",
    "content": "---\ntitle: \"ui revamp and usability improvements\"\ndate: \"Sep 8, 2022\"\n---\nnew features:\n- 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.\nunfortunately, the clipboard feature is not available to firefox users because mozilla didn't add proper support for clipboard api.\n- 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.\n- 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.\n\nnew looks:\n- main box has been revamped. it has lost its border, thick padding, and now feels light and fresh.\n- download button is now prettier, and has been tuned to make >> look just like the logo.\n- 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.\n- bottom buttons are prettier and easier to use on a phone. they're bigger and stretch out to sides, making them easier to press.\n\nfixes:\n- 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.\n- popup tabs have been slightly moved down to prevent popup content overlapping.\n- ui scalability has been improved."
  },
  {
    "path": "web/changelogs/3.6.3.md",
    "content": "---\ntitle: \"less disturbance\"\ndate: \"Oct 5, 2022\"\n---\nchangelog 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.\n\nyour old setting that disabled the changelog popup now applies to notifications.\n\nnew 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.\n\nother changes:\n- popups are now a bit wider, just so more content fits at once.\n- better interface scaling.\n- code is a bit cleaner now.\n- changed twitter api endpoint. there should no longer be any rate limits."
  },
  {
    "path": "web/changelogs/3.6.md",
    "content": "---\ntitle: \"improvements all around!\"\ndate: \"Sep 28, 2022\"\n---\n- download mode switcher is moving places, it's now right next to link input area.\n- smart mode has been renamed to auto mode, because this name is easier to understand.\n- all spacings in ui have been evened out. no more eye strain.\n- added support for twitter /video/1 links\n- clipboard button exception has been redone to prepare for adoption of readtext clipboard api in firefox.\n- cobalt is now using different tiktok api endpoint, because previous one got killed, just like the one before.\n- \"other\" settings tab has been cleaned up."
  },
  {
    "path": "web/changelogs/3.7.md",
    "content": "---\ntitle: \"support for multi media tweets is here!\"\ndate: \"Oct 9, 2022\"\n---\ncobalt now lets you save any of the videos or gifs in a tweet. even if there are many of them.\n\nsimply paste a link like you'd usually do and cobalt will ask what exactly you want to save.\n\nFIREFOX 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.\n\nhowever, 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.\n\nother changes:\n- 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.\n- cobalt is now properly viewable on phones with tiny screens, such as first gen iphone se.\n- scrollbars now should be visible only where they're needed.\n- brought back proper twitter api, because other one doesn't have multi media stuff (at least yet).\n- cleaned up some internal files, including main frontend js file.\n- reorganized some files in project directory, now you won't get lost when contributing or just looking through cobalt's code."
  },
  {
    "path": "web/changelogs/4.0.md",
    "content": "---\ntitle: \"better and faster than ever\"\ndate: \"Oct 24, 2022\"\n---\nthis update has a ton of improvements and new features.\n\nchanges you probably care about:\n- cobalt now has support for recorded twitter spaces! download the previous conversation no matter how long it was.\n- download speeds from youtube are at least 10 times better now. you're welcome.\n- both video and audio length limits have been extended to 2 hours.\n- 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.\n- 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.\n- soundcloud downloads have been fixed, too.\n\nless notable changes:\n- 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.\n- \"download audio\" button from image picker no longer stays on the screen after popup was closed.\n- clipboard button now shows up depending on your browser's support for it.\n- you can no longer manually hide the clipboard button, 'cause it's unnecessary.\n- small internal improvements such as separation of changelog version and title.\n- fair bit of internal clean up.\n\nif you want to help me implement covers for downloaded audios, [you can do it on github](https://github.com/imputnet/cobalt)."
  },
  {
    "path": "web/changelogs/4.1.md",
    "content": "---\ntitle: \"better tiktok image downloads\"\ndate: \"Oct 27, 2022\"\n---\nhere's what's up:\n- tiktok images are saved as .jpeg instead of .webp (finally, i know).\n- added support for image downloads from douyin.\n- fixed tiktok audio downloads from the image picker.\n- 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\n\nif 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."
  },
  {
    "path": "web/changelogs/4.2.md",
    "content": "---\ntitle: \"optimized quality picking and 8k video support\"\ndate: \"Nov 4, 2022\"\n---\n- this update fixes quality picking that was accidentally broken in 4.0 update.\n- 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.\n- default video quality for downloads from pc is now 1440p, and 720p for phones.\n- default video format is now mp4 for everyone.\n- default audio format is now mp3 for everyone.\n\nyou can always change new defaults back to whatever you prefer in settings.\n\nother changes:\n- added more clarity to quality picker description.\n- youtube video codecs are now right in the picker.\n- setup script is now easier to understand."
  },
  {
    "path": "web/changelogs/4.3.2.md",
    "content": "---\ntitle: \"twitter improvements & changelog overhaul\"\ndate: \"Nov 15, 2022\"\n---\n- you can download explicit content from twitter.\n- direct video links from twitter are properly supported (video/1, video/2, etc.).\n- changelog history got support for banners.\n- changelog categories are not messy anymore.\n- cobalt version in changelogs is now highlighted.\n- changelog history got separators to make text easier to read.\n- changelog history can be collapsed after loading.\n- download button takes less time to change back to pressable state.\n\nif you're a developer and would like to play around with cobalt's api, then read more about it in older changelogs below!"
  },
  {
    "path": "web/changelogs/4.3.md",
    "content": "---\ntitle: \"developers, developers, developers, developers\"\ndate: \"Nov 12, 2022\"\nbanner:\n    file: \"developers.webp\"\n    alt: \"steve ballmer going \\\"developers, developers, developers\\\"\"\n---\nthis update features a TON of improvements.\n\n[developers](https://www.youtube.com/watch?v=SaVTHG-Ev4k), you now can rely on cobalt for getting content from social media. the api has been revamped and [documentation](https://github.com/imputnet/cobalt/tree/main/docs/api.md) is now available. you can read more about API changes down below. go crazy, and have fun :D\n\nif you're not a developer, here's a list of changes that you probably care about:\n- rate limit is now approximately 8 times bigger. no more waiting, even if you want to download entirety of your tiktok \"for you\" page.\n- some updates will now have expressive banners, just like this one.\n- fixed what was causing an error when a youtube video had no description.\n- mp4 format button text should now be displayed properly, no matter if you touched the switcher or not.\n\nnext, the star of this update — improved api!\n- main endpoint now uses POST method instead of GET.\n- internal variables for preferences have been updated to be consistent and easier to understand.\n- ip address is now hashed right upon request, not somewhere deep inside the code.\n- global stream salt variable is no longer unnecessarily passed over a billion functions.\n- url and picker keys are now separate in the json response.\n- cobalt web app now correctly processes responses with \"success\" status.\n\nif you currently have a siri shortcut or some other script that uses the GET method, make sure to update it soon. this method is deprecated, limited, and will be removed entirely in coming updates.\n\nif you ever make something using cobalt's api, make sure to mention [@justusecobalt](https://twitter.com/justusecobalt) on twitter, i would absolutely love to see what you made."
  },
  {
    "path": "web/changelogs/4.4.md",
    "content": "---\ntitle: \"over 1 million monthly requests. thank you.\"\ndate: \"Nov 20, 2022\"\nbanner:\n    file: \"onemillionr.webp\"\n    alt: \"cobalt logo and a confetti emoji\"\n---\nthis is a huge milestone for me, i cannot express enough how grateful i am for each and every one of you.\nthank 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.\n\nthank you &amp;lt;3\n\nif you want to thank ME, check out the renovated donations tab, which now is also linked alongside bottom action buttons."
  },
  {
    "path": "web/changelogs/4.5.md",
    "content": "---\ntitle: \"better, faster, stronger, stable\"\ndate: \"Dec 6, 2022\"\nbanner:\n    file: \"meowthstrong.webp\"\n    alt: \"meowth stretching\"\n---\nyour favorite social media downloader just got even better! this update includes a ton of improvements and fixes.\n\nin fact, there are so many changes, i had to split them in sections.\n\nservice-related improvements:\n- vimeo module has been revamped, all sorts of videos should now be supported.\n- vimeo audio downloads! you now can download audios from more recent videos.\n- cobalt now supports all sorts of tumblr links. (even those scary ones from the mobile app)\n- vk clips support has been fixed. they rolled back the separation of videos and clips, so i had to do the same.\n- youtube videos with community warnings should now be possible to download.\nuser interface improvements:\n- list of supported services is now MUCH easier to read.\n- banners in changelog history should no longer overlap each other.\n- bullet points! they have a bit of extra padding, so it makes them stand out of the rest of text.\ninternal improvements:\n- cobalt will now match the link to regex when using ?u= query for autopasting it into input area.\n- better rate limiting: limiting now is done per minute, not per 20 minutes. this ensures less waiting and less attack area for request spammers.\n- moved to my own fork of ytdl-core, cause main project seems to have been abandoned. go check it out on [github](https://github.com/wukko/better-ytdl-core) or [npm](https://www.npmjs.com/package/better-ytdl-core)!\n- ALL user inputs are now properly sanitized on the server. that includes variables for POST api method, too.\n- \"got\" package has been (mostly) replaced by native fetch api. this should greatly reduce ram usage.\n- all unnecessary duplications of module imports have been gotten rid of. no more error passing strings from inside of service modules. you don't make mistakes only if you don't do anything, right?\n- other code optimizations. there's less clutter overall.\nhuge update, right? seems like everything's fixed now?\n\nnope, one issue still persists: sometimes youtube server drops packets for an audio file while cobalt's rendering the video for you. this results in abrupt cuts of audio. if you want to help solving this issue, [please feel free to do it on github!](https://github.com/imputnet/cobalt/issues/62)\n\nthank you for reading this, and thank you for sticking with cobalt and me."
  },
  {
    "path": "web/changelogs/4.6.md",
    "content": "---\ntitle: \"mute videos and proper soundcloud support\"\ndate: \"Dec 17, 2022\"\nbanner:\n    file: \"shutup.webp\"\n    alt: \"a cat yawning, with a crossed out loudspeaker icon next to it\"\n---\ni've been longing to implement both of these things, and here they finally are.\n\nservice-related improvements:\n- you now can download videos with no audio! simply enable the \"mute audio\" option in settings &gt; audio.\n- soundcloud module has been updated, and downloads should no longer break after some time.\nvisual improvements:\n- moved some things around in settings popup, and added separators where separation is needed.\n- updated some texts in english and russian.\n- version and commit hash have been joined together, now they're a single unit.\ninternal improvements:\n- updated api documentation to include isAudioMuted.\n- simplified the startup message.\n- created render elements for separator and explanation due to high duplication of them in the page.\n- fully deprecated GET method for API requests.\n- fixed some code quirks.\nhere's how soundcloud downloads got fixed:\n\npreviously, client_id was (stupidly) hardcoded. that means cobalt wasn't able to fetch song data if soundcloud web app got updated.\nnow, 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."
  },
  {
    "path": "web/changelogs/4.7.md",
    "content": "---\ntitle: \"we're better together! thank you for bug reports.\"\ndate: \"Jan 13, 2023\"\nbanner:\n    file: \"bettertogether.webp\"\n    alt: \"various different pokémon jumping in happiness\"\n---\nthis update includes a bunch of improvements, many of which were made thanks to the community :D\n\nservice-related improvements:\n- private soundcloud links are now supported (#68);\n- tiktok usernames with dots in them no longer confuse cobalt (#71);\n- .ogg files no longer wrongfully include a video channel (#67);\n- fixed an issue that caused cobalt to freak out when user attempted to download an audio from audio-only service with \"mute video\" option enabled.\n\nui improvements:\n- popup padding has been evened out. popups are now able to fit in more information on scroll, especially on mobile;\n- all buttons are now of even size and are displayed without any padding issues across all modern browsers and devices;\n- checkbox is no longer crippled on ios;\n- many explanation texts have been simplified to get rid of unnecessary bloat (no bullshit, remember?);\n- moved tiktok section in video settings higher due to higher priority;\n- fixed unexpectedly displayed scrollbars on switch rows in firefox.\n\nstability improvements:\n- ffmpeg process now should end upon finishing the render;\n- ffmpeg should also quit when download is abruptly cut off;\n- fixed a memory leak that was caused by misconfigured stream information caching (#63).\n\ninternal improvements:\n- requested streams are now stored in cache for 2 minutes instead of 1000 hours (yes, 1000 hours, i fucked up);\n- cached data is now reused if user requests same content within 2 minutes;\n- page render module is now even cleaner than before;\n- proper support for bullet-points in loc strings.\n\nyou can suggest features or report bugs on [github](https://github.com/imputnet/cobalt) or [twitter](https://twitter.com/justusecobalt). both work just fine, use whichever you're more comfortable with.\n\nthank you for using cobalt, and thank you for reading this changelog.\n\nyou're amazing, keep it up :)"
  },
  {
    "path": "web/changelogs/4.8.md",
    "content": "---\ntitle: \"prettier than ever\"\ndate: \"Jan 29, 2023\"\nbanner:\n    file: \"catmakeup.webp\"\n    alt: \"a cat being brushed with a powder makeup brush\"\n---\nthis version brings many visual improvements and a completely revamped \"about\" tab.\n\nwhat's new in \"about\" tab:\n- all information is now split into collapsible sections, making it easier to navigate.\n- added privacy policy to further prove that none of your data is collected.\n- added emoji to the page title to make it look consistent with other pages.\n- added mastodon account handle and link.\n- there are now short notes at the end of each section.\n- other changes that are too small to describe. just go check it out!\n\nvisual improvements:\n- less wasted space: paddings and margins have been reduced and optimized for usability, consistency, and overall beauty.\n- all [links](https://youtu.be/dQw4w9WgXcQ) are now in italic. it's much easier to tell them apart from <span class=\"text-backdrop\">regular highlights</span>.\n- error popup no longer looks broken and out of place.\n- download popup now has a proper close button, not something from 2.x era.\n- emoji are no longer selectable or draggable.\n- better scalability: desktop layout for home screen is shown if device viewport is wide enough to fit in three action buttons.\n- page shouldn't look broken on phones in landscape mode (i still highly recommend using cobalt in portrait mode).\n- removed bulletpoint padding. it was unnecessary.\n- updated some service names.\n\nas always, you can suggest features or report bugs on any platform listed in the \"support\" section of about tab.\n\nthank you for using cobalt. i hope you have a good day :)"
  },
  {
    "path": "web/changelogs/5.0.md",
    "content": "---\ntitle: \"it's all about attention to detail!\"\ndate: \"Feb 13, 2023\"\nbanner:\n    file: \"valentines.webp\"\n    alt: \"relaxed meowth with sakura petals falling in front of them\"\n---\nhappy valentine's day! i have an update for you, as a gift :D\n\ntl;dr: added support for <span class=\"text-backdrop\">reddit gifs</span>, fixed douyin downloads, fixed vimeo quality picking, revamped entirety of codebase, and many other fixes.\n\nhere's more info:\n\nthis update is mostly about cleaning up and polishing the codebase, but it also has some new features. here's what's up:\n\nservice-related improvements:\n- you now can download gifs from reddit!\n- attempting to download a video from douyin no longer throws an error (bytedance changed the api endpoint, yet again).\n- fixed quality picking for vimeo downloads.\n- fixed length limit check in vimeo module.\n- fixed support for \"user view\" vk clips links.\n- various twitter errors are now displayed correctly instead of falling back to the default error.\n- state of all services is now tested on each commit.\n\nui improvements:\n- cobalt social links no longer disappear if you have an aggressive ad blocking extension installed.\n- various localization improvements for both english and russian.\n- changed some service aliases to display full list of supported downloads.\n- added current branch information to version text (in settings).\n- fixed typos in older changelogs.\n\ninternal improvements:\n- <span class=\"text-backdrop\">everything</span> has been sanitized, improved, and refactored. code is now much easier to read and maintain.\n- rewrote and/or optimized all modules that were messy or inefficient.\n- all git interaction functions now store info in cache instead of fetching it every time the function is called.\n- added a test script that checks functionality of all supported services.\n- updated deepsource config. checks are more accurate now.\n- requests from internet explorer are now dropped entirely instead of redirecting people stuck in 90s to a proper browser download page. this was done to avoid (my) personal bias towards browsers.\n\ni put a ton of effort into this version, and i hope you like it as much as i do.\n\nthank you for using cobalt. there's so much more to come :)"
  },
  {
    "path": "web/changelogs/5.1.md",
    "content": "---\ntitle: \"the evil has been defeated\"\ndate: \"Feb 26, 2023\"\nbanner:\n    file: \"happymeowth.webp\"\n    alt: \"meowth jumping up into the sky very excitedly\"\n---\nhey, ever wanted to download a youtube video without a hassle? cobalt is here to help. this update fixes all issues related to youtube downloads.\nnot only that, but it also introduces features never before seen in a downloader, such as youtube dub downloads! read below to see what's up :)\n\n<span class=\"text-backdrop\">tl;dr:</span>\n\n- audio in youtube videos FINALLY no longer gets cut off.\n- you now can pick any video resolution you want (from 360p to 8k) and any possible youtube video codec (h264/av1/vp9).\n- you now can download youtube videos with dubs in your native language. just check settings > audio.\n- youtube processing has been vastly sped up.\n\nok, now onto the nerdy part of changelog. this update is pretty huge and includes improvements across the board.\n\nservice improvements:\n- all youtube functionality has been reworked. cobalt now relies on innertube apis, not web scraping.\n- random audio cut off issue has been fixed, let me know if it ever occurs again. (closes #62, #66, #75, #88).\n- added support for youtube dubs. currently it's using your browser's default language when enabled, but i have plans on making a picker. i'll ask people on twitter and mastodon if this feature is needed, and add a picker in next updates.\n- instead of adding more quality presets, i added granular quality options. pick whatever you like, from 360p up to 4320p (for all services, not just youtube).\n- replaced a format picker with codec picker for youtube. you can pick h264, av1, or vp9. all of them should work as expected (closes #88).\n- youtube audio files are now properly matched to corresponding video files.\n- it's now always possible to download pristine h264 720p/360p videos from youtube. these videos will work ANYWHERE, so they're default for mobile.\n- youtube requests are no longer permanently cached, ram usage should drop even further.\n- youtube video and audio file names now include codec and dub language when applicable.\n- max video and audio duration limits have been bumped up to 3 hours.\n- general performance of entire youtube download process has been greatly improved.\n- vk module has been reworked to be more compact and not make use of outdated technique of quality picking. should also be way more reliable.\n\ninternal improvements:\n- cleaned up services config, all constants have been moved directly to modules for quicker access.\n- matching module has been slightly cleaned up.\n\ninterface improvements:\n- many descriptions and error messages have been slightly tuned to be less wordy.\n- unnecessary title duplications in settings have been merged into one.\n- added more clarity to quality and codec descriptions.\n\nif you use cobalt api, please note that you have to update your creation to support new features.\n\nthis is the second batch of 5.x improvements, there's way more to come. thank you for being here, i really appreciate your support.\n\nif you want to thank me (the developer), there's a nice tab under this changelog that has \"donations\" text on it. anything helps me continue developing and hosting the friendliest media downloader :D"
  },
  {
    "path": "web/changelogs/5.2.md",
    "content": "---\ntitle: \"fastest one in the game\"\ndate: \"Mar 24, 2023\"\nbanner:\n    file: \"catspeed.webp\"\n    alt: \"a cat running very fast in an exercise wheel\"\n---\nhey, notice anything different? well, at very least the page loaded way faster! this update includes many improvements and fixes, but also some new features.\n\n<span class=\"text-backdrop\">tl;dr:</span>\n\n- twitter retweet links are now supported.\n- all vimeo videos should now be possible to download.\n- you now can download audio from vimeo.\n- it's now possible to pick between preferred vimeo download method in settings.\n- fixed issues related to tiktok, twitter, twitter spaces, and vimeo downloads.\n- overall cobalt performance should be MUCH better.\n\nservice improvements:\n- added support for twitter retweet links. now all kinds of tweet links are supported.\n- fixed the issue related to periods in tiktok usernames (#96).\n- fixed twitter spaces downloads.\n- added support for audio downloads from vimeo.\n- added ability to choose between \"progressive\" and \"dash\" vimeo downloads. go to settings > video to pick your preference.\n- fixed the issue related to vimeo quality picking.\n- fixed the issue when vimeo module wouldn't show appropriate errors and instead would fallback to default ones.\n- improved audio only downloads for some edge cases.\n- (hopefully) better youtube reliability.\n- temporarily disabled douyin support due to api endpoint cut off.\n\ninterface improvements:\n- merged clipboard and mode switcher rows into one for mobile view.\n- added left-handed layout toggle for those who prefer to have the clipboard button on left.\n- new custom-made clipboard icon. now it clearly indicates what it does.\n- improved english and russian localization. both are way more direct and less bloaty.\n- frontend page is now rendered once and is cached on disk instead of being rendered every time someone requests a page. this greatly improves page loading speeds and further reduces strain put on the server.\n- frontend page is now minimized just like js and css files. this should minimize traffic wasted on loading the page, along with minor loading speed improvement.\n- added proper checkbox icon for better clarity.\n- checkboxes are now stretched edge-to-edge on phone to be easier to manage for right-handed people.\n- removed button hover highlights on phones.\n- fixed button press animations for safari on ios.\n- fixed text selection on ios. previously you could select text or images anywhere, but now they're selectable in limited places, just like on other platforms.\n- frontend platform is now marked in settings: p is for pc; m is for mobile; i is for ios. this is done for possible future debugging and issue-solving.\n- better error messaging.\n\ninternal improvements:\n- better rate limiting, there should be way less cases of accidental limits.\n- added support for m3u8 playlists. this will be useful for future additions, and is currently used by vimeo module.\n- added support for \"chop\" stream format for vimeo downloads.\n- fixed vk user id extraction. i assumed the - in url was a separator, but it's actually a part of id.\n- completely reworked the vimeo module. it's much cleaner and better performant now.\n- minor clean ups across the board.\n\nnot really related to this update, but thank you for 50k monthly users! i really appreciate that you're still here, because that means i'm doing some things right :D"
  },
  {
    "path": "web/changelogs/5.3.md",
    "content": "---\ntitle: \"better looks, better feel\"\ndate: \"Apr 3, 2023\"\nbanner:\n    file: \"cattired.webp\"\n    alt: \"a cat laying on a sofa face down, wiggling its tail\"\n---\nthis update isn't as big as previous ones, but it still greatly enhances the cobalt experience.\n\nhere's what's up:\n- new mode switcher! elegant and 100% clear. should no longer cause any confusion. let me know if you like it better this way :D\n- wide paste button on mobile is back, but now it's even closer to your finger.\n- removed the weird grey chin on changelog banners.\n- removed left-handed layout toggle since it is no longer needed.\n- fixed input area display in chromium 112+.\n- centered the main action box.\n- cleaned up css of main action box to get rid of tricks and ensure correct display on all devices.\n- fixed a bug that'd cause notifications dots to disappear when an unrelated checkbox was checked.\n\nhopefully from now on i'll focus on adding support for more services.\nthank you for using cobalt. stay cool :)"
  },
  {
    "path": "web/changelogs/5.4.md",
    "content": "---\ntitle: \"instagram support, docker, and more!\"\ndate: \"Apr 24, 2023\"\nbanner:\n    file: \"catphonestand.webp\"\n    alt: \"a cat holding a phone under its chin while a person plays clash of clans on it\"\n---\nsomething many of you've been waiting for is finally here! try it out and let me know what you think :)\n\n<span class='text-backdrop'>tl;dr:</span>\n\n- added experimental instagram support! download any reels or videos you like, and make sure to report any issues you encounter. yes, you can convert either to audio.\n- fixed support for on.soundcloud links.\n- added share button to \"how to save?\" popup.\n- added docker support.\n\nservice improvements:\n- added experimental support for videos from instagram. currently only reels and post videos are downloadable, but i'm looking into ways to save high resolution photos too. if you experience any issues, please report them on either of support platforms.\n- fixed support for on.soundcloud share links. should work just as well as other versions!\n- fixed an issue that made some youtube videos impossible to download.\n\ninterface improvements:\n- new css-only checkmark! yes, i can't stop tinkering with it because slight flashing on svg load annoyed me. now it loads instantly (and also looks slightly better).\n- fixed copy animation.\n- minor localization improvements.\n- fixed the embed logo that i broke somewhere in between 5.3 and 5.4.\n\ninternal improvements:\n- now using nanoid for live render stream ids.\n- added support for docker. it's kind of clumsy because of how i get .git folder inside the container, but if you know how to do it better, feel free to make a pr.\n- cobalt now checks only for existence of environment variables, not exactly the .env file.\n- changed the way user ip address is retrieved for instances using cloudflare.\n- added ability to disable cors, both to setup script and environment variables.\n\ni can't believe how diverse and widespread cobalt has become. it's used in all fields: music production, education, content creation, and even game development. <span class='text-backdrop'>thank you</span>. this is absolutely nuts.\nif you don't mind sharing, please tell me about your use case. i'd really love to hear how you use cobalt and how i could make it even more useful for you."
  },
  {
    "path": "web/changelogs/6.0.md",
    "content": "---\ntitle: \"better reliability, new infrastructure, pinterest support, and way more!\"\ndate: \"June 7, 2023\"\nbanner:\n    file: \"catswitchboxes.webp\"\n    alt: \"a cat climbing into two empty boxes of asahi beer\"\n---\nhey! long time no see, hopefully over 40 changes will make up for it :)\n\ncobalt now has an official community discord server. you can go there for news, support, or just to chat. [go check it out!](https://discord.gg/pQPt8HBUPu)\n\n<span class='text-backdrop'>tl;dr</span>\n\n- new infra, new hosting structure, new main instance api url. developers, [get it here](https://github.com/imputnet/cobalt/blob/main/docs/api.md).\n- added support for pinterest, vine archive, tumblr audio, youtube vr videos.\n- better web app performance and look.\n- better stability thanks to load balancing.\n- (hopefully) no more random video/audio download drops.\n\nservice improvements:\n- added support for pinterest videos and stories (pr by [@Snazzah](https://github.com/imputnet/cobalt/commit/40291c4d24cb5f441cdddfd26104f149bc4ee27c)).\n- added support for tumblr audio. sorry, tumblr.\n- added support for youtube vr videos. please note that they're in youtube's proprietary ratio.\n- added support for vine archive.\n- added support for ancient vk videos in 240p.\n- fixed an issue related to muted video downloads from tumblr.\n- moved to twitter v2 api.\n- soundcloud share links are now processed without errors.\n\nui improvements:\n- lazy image loading. should significantly speed up the page load.\n- fixed checkbox width on mobile devices.\n- addition of a temporary urgent notice.\n- added hover border to all buttons.\n- less annoying donation button highlight.\n- more consistent color scheme.\n- added link to a discord server into about popup.\n- remember celebratory emoji changes? they've been fixed, and are now dynamically loaded!\n- changelog history now lets you try to load it again if first attempt failed for whatever reason.\n- padding (everywhere) has been slightly reduced to fit in more content and be consistent across ui.\n- added more info to the \"how to save\" popup for ios devices.\n- crypto wallet press-to-copy buttons now look like buttons.\n- improved ui layout for smallest screens (iphone 5, 5s, se, etc).\n- removed partial translations for sake of clarity and consistency.\n\ninternal improvements:\n- separated web and api servers. they're now completely independent and therefore more stress-resistant.\n- added a dedicated script for building the web app if you don't want to reload the frontend server.\n- web app building improvements.\n- async localization preloading.\n- consistent server start time reporting.\n- dynamic stream and ip hashing salt generation.\n\ninfrastructure improvements:\n- load balancing: your api requests are now sent to the least busy server. yes, there are now several of them with more to come in the future.\n- when possible, server in closest region is used instead of a far-away one. this should help with download speeds.\n- currently there are multiple servers in europe. i will let you know when (and if) i manage to get an american one.\n\nupdates for developers and instance hosters:\n- server info api endpoint: you can now check up on the api server of choice. it reports all the basic info you may need. [check the api docs](https://github.com/imputnet/cobalt/blob/main/docs/api.md#get-apiserverinfo) for more info.\n- api names: each and every api instance should have a distinctive name. this will be useful in the future :)\n- added docker compose sample config.\n- updated and more granular setup script.\n- better api scalability and faster server start up thanks to web and api separation.\n- added ability to specify ffmpeg threads. simply add ffmpegThreads to your environment variables!\n\ni'm still in awe from how popular cobalt has become. there are now over 200k of unique users monthly, and that number only keeps growing. i even had to come up with something to accommodate for larger traffic, it's absolutely insane.\n\nlove you all, have a great day :D"
  },
  {
    "path": "web/changelogs/6.2.md",
    "content": "---\ntitle: \"all network issues have been fixed!\"\ndate: \"June 27 2023\"\nbanner:\n    file: \"meowthhammer.webp\"\n    alt: \"meowth plush holding a hammer in real life\"\n---\nhey! 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.\nBUT i'm happy to announce that i've optimized all nodes! <span class=\"text-backdrop\">there should no longer be any networking issues</span>.\n\nenjoy stable experience while i work in background to make cobalt even better :)\n\nhere's what's new in this update:\n- better button contrast in both themes. \n- button highlight in light theme now actually looks like a highlight.\n- removed ip gate for streamables and updated privacy policy to reflect this change.\n- streamable links now last for 20 seconds instead of 2 minutes.\n- cleaned up stream verification algorithm. now the same function doesn't run 4 times in a row.\n- removed deprecated way of hosting a cobalt instance.\n\nthank you for sticking with cobalt, and i hope you have a great day :D\n\nbanner photo is by [@halftroller](https://twitter.com/halftroller) on twitter, thank you so much!"
  },
  {
    "path": "web/changelogs/7.0.md",
    "content": "---\ntitle: \"biggest ui refresh yet!\"\ndate: \"August 15, 2023\"\nbanner:\n    file: \"meowthcooking.webp\"\n    alt: \"meowth handling orders in a restaurant\"\n---\nhey! this update is huge and mostly aimed to refresh the ui, but there are also some other nice fixes/additions. read below for more info :)\n\n<span class=\"text-backdrop\">tl;dr:</span>\n\n- entirety of web app has been refreshed. it's more prettier and optimized than ever, both on phone and desktop.\n- if you're on ios, try adding cobalt to home screen! it'll look and act like a native app.\n- all soundcloud links are now supported and audio quality is higher than before.\n- all x (previously twitter) links are now supported and work properly.\n- newer reddit videos are downloadable now.\n- added some sort of eula, list of keyboard shortcuts, updated privacy policy for more clarity. check it all in refreshed about tab!\n- cobalt now lets you know if your browser doesn't support clipboard pasting and helps you fix it.\n\n<span class=\"text-backdrop\">accessibility notice:</span>\nthis update includes animations and transparency, if you'd like to disable any or all of them, head to settings > other > accessibility.\n\n<span class=\"text-backdrop\">[full changelog]</span>\n\nservice improvements:\n- fixed unexpected 502 errors when downloading newer reddit videos.\n- newer reddit videos (with audio) are downloadable now.\n- upgraded soundcloud downloads to use higher audio quality than before.\n- all soundcloud links are now supported.\n- added support for x.com urls.\n- changed twitter api once again. now everything works, again.\n\nweb improvements:\n- all-new matte glass aesthetic, applied to revamped popup headers, tab selectors, and also small popups.\n- rounded corners everywhere! cobalt is now safe for everyone who can't handle sharp objects.\n- paddings everywhere are smaller, more content fits on the screen at once.\n- optimized installed web app to look and act like a native app, especially on ios.\n- added update release dates to changelogs.\n- cobalt now lets you know if your browser doesn't support clipboard api and helps you fix it.\n- refreshed all popups: less padding, more content.\n- completely remade error and download popups, they're consistent with the rest of refreshed design.\n- refreshed the look of entire changelog tab: separated title and version/commit, made title bigger, evened out all paddings.\n- replaced close button with back button, moved it to left.\n- added interaction animations.\n- added more keyboard shorcuts.\n- added a list of keyboard shortcuts to about tab.\n- added eula to about tab. check it out.\n- added more accessibility options, put them all into one category. you can disable animations and transparency if you want to.\n- added a link to self-troubleshooting guide to about tab.\n- renamed 2160p and 4320p to 4k and 8k respectfully for better clarity.\n- popups now work without any weird workarounds, especially on mobile. they're clean and nice.\n- home screen now also works without any weird workarounds. it is also clean and nice.\n- optimized css of almost all ui elements. should be even more consistent across platforms now.\n- added ability to translate \"cobalt\" more in-depth localization. for example, in russian \"cobalt\" is now \"кобальт\", that's the style i'll be going with from now on.\n- updated many localization strings for more clarity.\n- removed ability to change the app name dynamically in all locations. cobalt is a sustained app name.\n- updated donation and privacy policy texts for more clarity in both english and russian.\n- home screen now smoothly fades in instead of popping in.\n- proper banner loading. no more jumping text!\n- proper banner error handling. if banner wasn't loaded, it'll simply go grey instead of disappearing.\n- links are no longer italic and are instead underlined.\n- collapsible lists now have corresponding emoji.\n- donate button is now highlighted with magenta instead of white.\n- proper dropdown arrow.\n- removed 6.0 api fallback.\n- fixed celebrations emoji. again.\n- cleaned up all related frontend modules, especially page.js.\n- urgent notice is now a js element, not a static piece of text. can be updated easily.\n\napi improvements:\n- now catching all json api related errors.\n- moved on demand blocks to web server, now changelog can be updated independently from preferred api server.\n- now sending standard rate limiting headers.\n- better readability in source.\n\nother improvements:\n- renamed docker-compose.yml.example to docker-compose.example.yml for linting in code editors.\n- added a wiki with wip troubleshooting guide on github. more guides are coming soon!\n\nthat's a ton of changes! i really hope you like this update as much as i do.\n\nif you experience any issues, feel free to contact me on any platform listed in about tab! i'd love to hear back from you.\n\nthank you for sticking with me and cobalt, i hope you have THE best day :D"
  },
  {
    "path": "web/changelogs/7.1.md",
    "content": "---\ntitle: \"instagram, streamable, video metadata, and more!\"\ndate: \"August 20, 2023\"\nbanner:\n    file: \"meowthproductions.webp\"\n    alt: \"meowth roaring in a fancy circle, à la MGM studios intro\"\n---\nservice improvements:\n- extended instagram support: high quality photos, videos, reels. everything should work without any issues, enjoy! :)\n- added support for streamable.com (thanks to [#179](https://github.com/imputnet/cobalt/pull/179))\n- added video metadata to youtube videos.\n- fixed vk video downloads.\n- vxtwitter links are now supported.\n- fixed support for youtube audio dubs.\n\nui improvements:\n- fixed picker popup: it's now scrollable in all cases and clickable areas don't overlap each other.\n\nbackend improvements:\n- cobalt will now let you know if something goes wrong during video download instead of nuking the stream.\n- added support for cookies (thanks to [#177](https://github.com/imputnet/cobalt/pull/177))\n- 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.\n\ninternal improvements:\n- moved host overrides into its own module.\n- minor clean ups.\n\neven more cool stuff is coming in future updates! thank you for using cobalt :D"
  },
  {
    "path": "web/changelogs/7.11.md",
    "content": "---\ntitle: \"cache encryption, meowbalt, dailymotion, bilibili, and much more!\"\ndate: \"March 6, 2024\"\nbanner:\n    file: \"meowth7eleven.webp\"\n    alt: \"meowth plush in front of 7-eleven store\"\n---\ncobalt may not have as many groceries as 7-eleven, but it sure does have lots of big changes in this update!\n\n- all cached stream info is now encrypted and can only be decrypted with a link you get from cobalt.\n- new popup style featuring meowbalt, cobalt's speedy mascot. you will see him more often from now on!\n- added support for dailymotion (including short links).\n- added support for bilibili.tv, fixed support for bilibili.com, and added support for all related short links.\n- added support for unlisted vimeo links.\n- added support for tumblr audio and revamped the entire module.\n- added support for embed ok.ru links.\n\nwe also updated the privacy policy to reflect the addition of data encryption, go check it out.\n\nfor people with iphones:\n- clearer ios saving tutorial.\n- added \"save to files\" ios shortcut.\n- updated save to photos shortcut.\n\nmake sure to save both shortcuts and read the updated tutorial!\n\nfor people who host a cobalt instance:\n- updated all environment variables TO_BE_LIKE_THIS. time to update your configs! for now cobalt is backwards compatible with old variable names, but it won't last forever.\n- added a list of all environment variables and their descriptions to [run-an-instance doc](https://github.com/imputnet/cobalt/blob/main/docs/run-an-instance.md#list-of-all-environment-variables).\n- updated [cookie file example](https://github.com/imputnet/cobalt/blob/main/docs/examples/cookies.example.json) with more services and improved examples.\n- updated [docker compose example](https://github.com/imputnet/cobalt/blob/main/docs/examples/docker-compose.example.yml) with better explanations and up-to-date env variable samples.\n- updated some packages to get rid of all unnecessary messages in console.\n\nwant to host an instance? [learn how to do it here](https://github.com/imputnet/cobalt/blob/main/docs/run-an-instance.md).\n\nfrontend changes:\n- removed migration popup.\n- corners across ui are even more round now.\n- bottom glass bkg in popups is no longer rounded on top right.\n- small popup no longer stretches like gum, it's fixed in size on desktop.\n- small popup animation no longer lags on mobile.\n- better ui scaling across resolutions.\n- updated donation text.\n\nthank you for using cobalt, all 750k of you. hope you like this update as much as we enjoyed making it :D"
  },
  {
    "path": "web/changelogs/7.13.md",
    "content": "---\ntitle: \"better ux, improvements for youtube, twitter, tiktok, instagram, and more!\"\ndate: \"May 5, 2024\"\nbanner:\n    file: \"meowthbusinessman.webp\"\n    alt: \"photo of a businessman holding hands together (merkel-raute pose) with meowth plush head.\"\n---\nlong time no see! well, actually, you've been using the latest version for some time now. we've moved to a rolling release scheme, allowing for speedy update rollouts :)\n\nsince 7.11, there has been a ton of changes. here are the most notable of them:\n- youtube downloads are now faster and more reliable than ever.\n- all posts from twitter are now downloadable, including sensitive ones.\n- you now can download tiktok videos in 1080p h265! just enable h265 support in settings > video.\n- added support for sharing links directly to the cobalt web app on android.\n- added 240p and 144p quality options to the quality picker in settings (for some reason, many of you wanted this).\n- pasting a link with additional text around it will now work; cobalt will extract the link for you (works only via the paste button).\n- added anonymous traffic analytics by plausible. we're using a selfhosted instance and don't collect any identifiable information about you. you can learn more in about > privacy policy. you can also opt out of anonymous analytics in settings > other.\n\nservice support improvements:\n- implemented internal streams functionality, allowing for more fine-grained file streaming and therefore proper youtube support.\n- added fallback to m4a if opus isn't available for youtube.\n- added a total of 7 ways to get instagram post info, including mobile api, embed, and graphql api. absolute torture.\n- added support for reddit user posts.\n- updated the way tiktok downloads are handled for better reliability and 1080p support.\n- added tiktok author's username to filename.\n- added support for rutube shorts and yappy videos.\n- added support for m.soundcloud.com links.\n- added support for new post and reel links from instagram.\n- added support for photo twitter links, only used for gifs.\n- added support for m.bilibili.com links.\n- added support for new type of vimeo links.\n- added support for ddinstagram.com links.\n- updated youtube codec info in settings to display the fact that av1 is a better choice now.\n- updated best audio picking for tiktok and soundcloud.\n- changed the youtube client to web, since android client no longer works.\n- removed the vimeo download type switcher, as it should've always been automatic instead.\n- removed an ability to enable the tiktok watermark, as it no longer includes the author's username.\n\nui & ux improvements:\n- youtube audio dub switcher is now a toggle with a much easier to understand description.\n- meowbalt now sticks out on the left side of download popup on desktop.\n- updated \"made with love\" text to include the research & dev team behind cobalt, imput.\n- fixed grammar of russian localization.\n- rounded corners are now correctly rendered across all browsers.\n- various minor improvements, including smaller button padding.\n- removed the notification (red dot) functionality as the most recent changelog is already always on screen.\n- removed settings migration from the old domain.\n\nother changes:\n- various docs updates in github repo, making sure they're functional across branches and forks.\n- major codebase cleanup.\n\nthank you for using cobalt, and thank you for being one of our 900k friends! i hope you like this update as much as we liked making it.\n\nwe're committed to keeping cobalt the best way to save what you love without ads or invasion of your privacy. there's a ton of cool stuff to come soon; stay tuned and have an amazing rest of your day &amp;lt;3\n\nif you want to help our goal of a better internet for everyone, just share cobalt with a friend!\n\n(original photo of a man in a suit by benzoix on freepik)"
  },
  {
    "path": "web/changelogs/7.14.md",
    "content": "---\ntitle: \"now helping over 1 million people monthly\"\ndate: \"May 17, 2024\"\nbanner:\n    file: \"millionusers.webp\"\n    alt: \"collage of two photos, side by side. left photo: brown cake with 7 lit candles forming 1000000 and one ferrero rocher candy in the middle with cobalt (double greater than symbol) logo on it. right photo: chocolate cake with 7 lit candles forming 1000000 and cobalt logo formed with whipped cream on the cake. two plushes of meowth and pompompurin in party hats are seen behind the cake.\"\n---\nyesterday, cobalt hit 1 million users around the world! it's an absolutely insane milestone for us and we're incredibly grateful to everyone saving and creating what they love with help of cobalt. thank you for being our friends.\n\nin anticipation of 7 figure user count, we've revamped the cobalt codebase and infrastructure to be faster and more reliable than ever. a combination of many changes has resulted into incredible download speeds (up to 30 MB/s, as tested by both developers in europe).\n\nnote: there's no backend instance in asia just yet, so if you're there, you might experience average speeds *for now*. you can help us afford a dedicated server in asia by donating to cobalt in the \"donate\" menu.\n\n<span class=\"text-backdrop\">changes since the last major update</span>\n\nservice improvements:\n- youtube music support on the main instance is back!\n- added support for pinterest images and gifs.\n- cobalt will now use original soundcloud mp3 file when available.\n- fixed a youtube bug that prevented some videos from downloading.\n\nui/ux improvements:\n- cobalt web app is now fully optimized for ipad. you can add it to home screen from share menu to make it act like a native app!\n- majorly reduced vertical padding when viewing cobalt in mobile web browser, allowing for more content at once. most noticeable on smaller screens.\n- status bar color is now dynamic in the web browser on ios and web app on android.\n- web app on android feels way more native than before.\n- filename style icons are no longer blurry in safari.\n- changelog notification no longer overlaps with dynamic island on newer iphones when cobalt is installed as a web app.\n- fixed safe area padding.\n\nother changes:\n- added support for [freebind](https://github.com/imputnet/freebind.js), made by one of the cobalt developers.\n- rate limit and max video length limits are now customizable through [environment variables](https://github.com/imputnet/cobalt/blob/main/docs/run-an-instance.md#variables-for-api).\n- cobalt api now returns rate limit headers at all times.\n- majorly cleaned up the codebase: removed unnecessary functions, rewrote those that were cryptic and confusing. it's way more comprehensible and contribution-friendly than ever before.\n- moved the [cobalt repo](https://github.com/imputnet/cobalt) to our organization on github. everything stayed the same and all old links link back to it.\n\nnote for instance hosters:\nalong with cobalt repo, the docker image also moved! please update the url for it in your config along with watchtower args to include restarting containers (just in case) as seen in [updated docker compose example](https://github.com/imputnet/cobalt/blob/main/docs/examples/docker-compose.example.yml). we're mirroring packages to old url for now, but it won't last forever.\n\nthat's it for now! hope you have an amazing day and share the 1 million celebration with us :)\n\njoin our [discord server](https://discord.gg/pQPt8HBUPu) to discuss everything cobalt there"
  },
  {
    "path": "web/changelogs/7.3.md",
    "content": "---\ntitle: \"extended video length limit, metadata toggle, ui improvements, and more!\"\ndate: \"September 6, 2023\"\nbanner:\n    file: \"meowthsnap.webp\"\n    alt: \"cartoon meowth pointing paw dramatically and saying something\"\n---\nthis update gives cobalt a sharp look in chromium browsers and makes it even more useful than before. check out the full changelog below!\n\nservice improvements:\n- increased video length limit from 3 hours to 5 hours. feel free to download lectures you need :)\n- you can now disable file metadata in settings.\n- fixed a bug which previously caused some downloads to end up being 0 bytes.\n\nui improvements:\n- fixed clickable area for urgent notice (text on top).\n- fixed blurry header in chrome.\n- fixed blurry tab bar in chrome.\n- fixed blurry switches in chrome.\n- fixed weirdly rounded corners in popups.\n- fixed 1px gap on edges of various elements in popup in chrome.\n- fixed overscrolling in other settings tab on ios.\n- fixed unexpected button highlight effect on phones.\n- removed outdated fixes for tiny screens.\n\nother improvements:\n- cobalt web & api start faster than before, additional preparation functions aren't unexpectedly run anymore.\n- cobalt is now available as a docker package. check it out on [github](https://github.com/imputnet/cobalt/pkgs/container/cobalt).\n\nthank you for being here. i hope you have a great day :D"
  },
  {
    "path": "web/changelogs/7.4.md",
    "content": "---\ntitle: \"new domain, what's coming in future, bug fixes, and more!\"\ndate: \"September 9, 2023\"\nbanner:\n    file: \"newdomain.webp\"\n    alt: \"text: new domain, same cobalt\"\n---\ncobalt is finally moving to its own domain! many of you have been anticipating this, and many kept forgetting the link due to how cryptic it was.\n\nwell, worry no more - <span class=\"text-backdrop\">cobalt.tools</span> is here.\n\nif you haven't yet, open [co.wukko.me](https://co.wukko.me) to transfer your settings here! no additional action from you is required. just open the old link and cobalt will do everything for you :)\n\nmake sure to <span class=\"text-backdrop\">update your bookmarks</span> and reinstall the web app!\n\nhere's what domain change means:\n- still no ads, same owner, same features, same reliability. just a way more rememberable link (it's literally two words).\n- cobalt.tools makes it clear that cobalt is a tool and that it's \"cobalt\", not \"wukko\".\n- i can host various versions of cobalt on subdomains without links looking awkward.\n- i can host cobalt-related websites without polluting my personal domain's dns (such as crowdin).\n- i stand by same privacy policies (and in fact am using the same exact server as before).\n\nthe domain change is required for the future of cobalt.\n\nhere's what's coming soon:\n- support for many top-requested sites, such as (but not limited to) twitch and niconico.\n- education version of cobalt, as often requested by students and educators.\n- major localization system upgrade, allowing for simpler community contributions.\n- region-specific versions with 100% translations and tweaks.\n- native clients for desktop and mobile (not sure about this one, i'm no superman).\n- ...and more!\n\nnow, here's what's new in 7.4:\n- tabs in popups now scroll to top on tab bar tap.\n- padding across web app was tuned.\n- (obviously) a migration agent. soon will be used for importing and exporting settings.\n- some minor clean ups in codebase.\n\nif you want to help cobalt achieve goals listed above, consider donating! donations are the only way i can keep cobalt ad-less, powerful, (basically) limitless, and also 100% free.\n\nin fact, donations have helped me grow cobalt more than i've ever anticipated. just imagine how much better it will be in a year.\n\ngo to donations down below to find ways to donate!\n\nthank you for reading through all of this. i hope you enjoy this update and have a great day :D"
  },
  {
    "path": "web/changelogs/7.5.md",
    "content": "---\ntitle: \"support for twitch clips and rutube!\"\ndate: \"September 16, 2023\"\nbanner:\n    file: \"twitchupdate.webp\"\n    alt: \"meowth plush staring into the camera, laptop with generic purple service in the background\"\n---\nhey! this update (finally) adds support for twitch clips and rutube, among other smaller changes.\n\nservice improvements:\n- added support for twitch clips. no vods, they're unnecessary. just clip whatever you want to download!\n- added support for rutube in case you ever wanted to download something russian.\n\ninterface improvements:\n- added a note about cobalt not being affiliated with any supported services.\n- added a note about meta (the company) in russian.\n- better russian localization. will keep improving it to make it sound not so robotic over time.\n\nother improvements:\n- all official servers are now using the docker package. and so should you!\n- moved the load balancer to poland. requests should be slightly faster now.\n- minor codebase clean up.\n\nif you're confused about the new domain, read the older changelog! just scroll lower and press \"expand\".\n\ni hope you find this update useful and have a wonderful day :)\n\nbtw, cobalt has a pretty active community server on discord. go to about > support & source code to join!"
  },
  {
    "path": "web/changelogs/7.6.md",
    "content": "---\ntitle: \"customizable file names, instagram stories, and first cobalt sponsor!\"\ndate: \"October 15, 2023\"\nbanner:\n    file: \"meowthcenter.webp\"\n    alt: \"meowth plush in a datacenter wearing a hardhat, wielding a hammer\"\n---\nas many have (very) often requested, cobalt now lets you pick between several file name format styles!\ngo to <span class=\"text-backdrop\">settings > other</span> and change it to whichever you like! there's a preview of each style, so you know how exactly files are gonna look like.\n\nif you liked file names the way they were before, don't worry: classic style is still the default :)\n\non a different but not any less important note: cobalt is now sponsored by [royalehosting.net](https://royalehosting.net/)!\noverall service performance and stability is gonna be better, but also more content will be possible to download thanks to geniuine server locations. and yes, still no ads or trackers.\n\nthis update also includes a bunch of other changes, check them out:\n\nservice improvements:\n- added support for instagram stories thanks to [#194](https://github.com/imputnet/cobalt/pull/194).\n- fixed reddit support thanks to [#221](https://github.com/imputnet/cobalt/pull/221).\n- added support for rich file names for youtube, vimeo, soundcloud, rutube, and vk.\n- numbers and emoji no longer disappear from file name and metadata.\n- mute and audio dub file name tags don't appear together anymore.\n- youtube: dub file name tag doesn't appear anymore if audio track is default.\n\ninterface improvements:\n- added a list of sponsors to about tab. if you host an instance, it's disabled by default, but can be enabled with showSponsors env variable.\n- about button now opens about tab when no new changelog is available.\n- fixed download button thickness on ios.\n\nyou now can reach out to cobalt via email for support! it's located in the about tab along with other socials, such as discord.\n\ni hope you enjoy this long-awaited update and have a blissful day :D"
  },
  {
    "path": "web/changelogs/7.7.md",
    "content": "---\ntitle: \"bugfixes and better downloads!\"\ndate: \"December 2, 2023\"\nbanner:\n    file: \"meowthpolishegg.webp\"\n    alt: \"meowth polishing a togepi egg\"\n---\nthis update fixes various issues with supported services. no new features yet, but twitter fix is surely something good to have in the meantime!\n\nservice improvements:\n- broken twitter videos are now automatically fixed by cobalt.\n- all vimeo videos and audios should now be possible to download.\n- vimeo: fixed short resolution displayed in \"basic\" and \"pretty\" filename styles.\n\ninterface improvements:\n- streamables are now easier to save on ios.\n\ninternal improvements:\n- port env variable is now not strictly necessary for cobalt to run.\n- minor clean up.\n\nchanges since 7.6:\n- fix for an issue related to youtube dubs.\n- fixed a memory leak related to live renders.\n- handling all errors related to twitter downloads.\n- fixed support for reddit links in various languages.\n- added rich filenames support for twitch clips.\n- updated support and donation lists.\n\nstay tuned for future updates and have a great day :D"
  },
  {
    "path": "web/changelogs/7.8.md",
    "content": "---\ntitle: \"new years clean up! bug fixes and fresh look for the home page\"\ndate: \"December 25, 2023\"\nbanner:\n    file: \"catroomba.webp\"\n    alt: \"a cat riding a roomba vacuum\"\n---\nmerry christmas and happy new year! this update fixes several (very annoying) bugs to help you enjoy your holidays better.\n\nyou 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\n\nservice improvements:\n- [#264](https://github.com/imputnet/cobalt/issues/264) anything that includes a period in the url should be possible to download (including instagram stories).\n- [#273](https://github.com/imputnet/cobalt/issues/273) soundcloud: falling back to mp3 instead of refusing to download the song at all.\n- [#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.\n- tlds are parsed and validated correctly (e.g. \"pinterest.co.uk\" works now).\n- fixvx.com links are now supported.\n\ninterface improvements:\n- cleaner and more consistent home page layout.\n- cleaned up support section in \"about\". also includes a link to the status page.\n\ninternal improvements:\n- urls, subdomains, and tlds are properly validated.\n- minor clean up.\n\nchanges since 7.7:\n- made terms and ethics more descriptive.\n- fix only affected twitter videos.\n- fixed quick ⌘+V pasting on mac.\n- now catching even more youtube-related errors.\n\nthis might not seem like a lot, but even smaller changes make a difference!\n\nenjoy this update and the rest of your day :D"
  },
  {
    "path": "web/changelogs/7.9.md",
    "content": "---\ntitle: \"twitter gifs, pinterest, ok.ru, and more!\"\ndate: \"January 17, 2024\"\nbanner:\n    file: \"meowthball.webp\"\n    alt: \"meowth rolling on a big catnip ball\"\n---\nyes, you read that right. cobalt now lets you convert any twitter gif to an actual .gif file! (finally)\njust go to settings and enable this feature :)\n\nservice improvements:\n- added an option to [convert gifs from twitter](https://github.com/imputnet/cobalt/issues/250) into actual .gif format. files will be bigger and lower quality, but maybe you want that.\n- pinterest support has been completely redone, now all videos ([and even pin.it links](https://github.com/imputnet/cobalt/issues/160)) are supported.\n- added [support for ok.ru](https://github.com/imputnet/cobalt/issues/322) in case you're a russian grandma.\n- now processing [all reddit links](https://github.com/imputnet/cobalt/issues/318) (including old.reddit.com).\n- [instagram live vods](https://github.com/imputnet/cobalt/issues/316) are now supported.\n- fixed a [rare vimeo bug](https://github.com/imputnet/cobalt/issues/289) related to 1440p videos.\n\nother improvements:\n- ui fade in animation is no longer present if you've disabled animations.\n- all images now have alt descriptions.\n- cobalt html is now [biblically correct](https://github.com/imputnet/cobalt/issues/317) and follows the html spec.\n- lots of cleaning up.\n\npatches since 7.8:\n- shift+key [shortcuts are now ignored](https://github.com/imputnet/cobalt/issues/288) if url bar is focused.\n- longer soundcloud links are now supported, also catching more tiktok-related errors.\n- removed mastodon from support links as that account is no longer active.\n- added ability to download a specific video from multi media tweets and support for /mediaViewer links.\n- fixed [modal blurriness](https://github.com/imputnet/cobalt/issues/309) in chromium.\n- minor html changes (road to biblically correct one).\n\nlots of long-awaited updates (especially twitter gifs), hope you enjoy them and have a great day :D"
  },
  {
    "path": "web/eslint.config.js",
    "content": "// @ts-check\n\nimport eslint from '@eslint/js';\nimport tseslint from 'typescript-eslint';\n\nexport default tseslint.config(\n    eslint.configs.recommended,\n    ...tseslint.configs.recommended,\n);\n"
  },
  {
    "path": "web/i18n/en/a11y/dialog.json",
    "content": "{\n    \"picker.item.photo\": \"photo thumbnail\",\n    \"picker.item.video\": \"video thumbnail\",\n    \"picker.item.gif\": \"gif thumbnail\"\n}\n"
  },
  {
    "path": "web/i18n/en/a11y/donate.json",
    "content": "{\n    \"share.qr.expand\": \"qr code. press to expand.\",\n    \"share.qr.collapse\": \"expanded qr code. press to collapse.\"\n}\n"
  },
  {
    "path": "web/i18n/en/a11y/general.json",
    "content": "{\n    \"back\": \"go back\"\n}\n"
  },
  {
    "path": "web/i18n/en/a11y/queue.json",
    "content": "{\n    \"status.default\": \"processing queue\",\n    \"status.completed\": \"processing queue. all tasks are completed.\",\n    \"status.ongoing\": \"processing queue. ongoing tasks.\"\n}\n"
  },
  {
    "path": "web/i18n/en/a11y/save.json",
    "content": "{\n    \"link_area\": \"link input area\",\n    \"link_area.turnstile\": \"link input area. checking if you're not a robot.\",\n    \"clear_input\": \"clear input\",\n    \"download\": \"download\",\n    \"download.think\": \"processing the link...\",\n    \"download.check\": \"verifying download...\",\n    \"download.done\": \"downloading done\",\n    \"download.error\": \"downloading error\",\n\n    \"tutorial.shortcut.photos\": \"add photos shortcut\",\n    \"tutorial.shortcut.files\": \"add files shortcut\"\n}\n"
  },
  {
    "path": "web/i18n/en/a11y/tabs.json",
    "content": "{\n    \"tab_panel\": \"tabs panel\"\n}\n"
  },
  {
    "path": "web/i18n/en/about/credits.md",
    "content": "<script lang=\"ts\">\n    import { contacts, docs, partners } from \"$lib/env\";\n    import { t } from \"$lib/i18n/translations\";\n\n    import SectionHeading from \"$components/misc/SectionHeading.svelte\";\n    import BetaTesters from \"$components/misc/BetaTesters.svelte\";\n</script>\n\n<section id=\"imput\">\n<SectionHeading\n    title=\"imput\"\n    sectionId=\"imput\"\n/>\n\ncobalt is made with love and care by [imput](https://imput.net/) ❤️\n\nwe're a small team of two guys, but we work really hard to make great software that benefits everyone.\nif you like our work, please consider supporting it on the [donate page](/donate)!\n</section>\n\n<section id=\"testers\">\n<SectionHeading\n    title={$t(\"about.heading.testers\")}\n    sectionId=\"testers\"\n/>\n\nhuge shout-out to our testers for testing updates early and making sure they're stable.\nthey also helped us ship cobalt 10!\n<BetaTesters />\n\nall links are external and lead to their personal websites or social media.\n</section>\n\n<section id=\"partners\">\n<SectionHeading\n    title={$t(\"about.heading.partners\")}\n    sectionId=\"partners\"\n/>\n\na portion of cobalt's processing infrastructure\nis provided by our long-standing partner, [royalehosting.net]({partners.royalehosting})!\n</section>\n\n<section id=\"meowbalt\">\n<SectionHeading\n    title={$t(\"general.meowbalt\")}\n    sectionId=\"meowbalt\"\n/>\n\nmeowbalt is cobalt's speedy mascot, a very expressive cat who loves fast internet.\n\nall amazing art of meowbalt that you see in cobalt\nwas made by [GlitchyPSI](https://glitchypsi.xyz/).\nhe's also the original creator of the character.\n\nimput holds legal rights to meowbalt's character design,\nbut not specific artworks that were created by GlitchyPSI.\n\nwe love meowbalt, so we have to set a few rules in place to protect him:\n- you cannot use meowbalt's character design in any form that isn't fan art.\n- you cannot use meowbalt's design or artworks commercially.\n- you cannot use meowbalt's design or artworks in your own projects.\n- you cannot use or modify GlitchyPSI's artworks of meowbalt in any form.\n\nif you create fan art of meowbalt, please share it in\n[our discord server](/about/community), we'd love to see it!\n</section>\n\n<section id=\"licenses\">\n<SectionHeading\n    title={$t(\"about.heading.licenses\")}\n    sectionId=\"licenses\"\n/>\n\ncobalt api (processing server) code is open source and licensed under [AGPL-3.0]({docs.apiLicense}).\n\ncobalt frontend code is [source first](https://sourcefirst.com/) and is licensed under [CC-BY-NC-SA 4.0]({docs.webLicense}).\n\nwe had to make frontend source first to stop grifters from profiting off our work\n& from creating malicious clones that deceive people and hurt our public identity.\nother than commercial use, it follows same principles as many open source licenses.\n\nwe rely on many open source libraries, but also create & distribute our own.\nyou can see the full list of dependencies on [github]({contacts.github})!\n</section>\n"
  },
  {
    "path": "web/i18n/en/about/general.md",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n    import { contacts, docs } from \"$lib/env\";\n\n    import SectionHeading from \"$components/misc/SectionHeading.svelte\";\n</script>\n\n<section id=\"summary\">\n<SectionHeading\n    title={$t(\"about.heading.summary\")}\n    sectionId=\"summary\"\n/>\n\ncobalt helps you save anything from your favorite websites: video, audio, photos or gifs. just paste the link and you're ready to rock!\n\nno ads, trackers, paywalls, or other nonsense. just a convenient web app that works anywhere, whenever you need it.\n</section>\n\n<section id=\"motivation\">\n<SectionHeading\n    title={$t(\"about.heading.motivation\")}\n    sectionId=\"motivation\"\n/>\n\ncobalt was created for public benefit, to protect people from ads and malware pushed by alternative downloaders.\nwe believe that the best software is safe, open, and accessible. all imput project follow these basic principles.\n</section>\n\n<section id=\"privacy-efficiency\">\n<SectionHeading\n    title={$t(\"about.heading.privacy_efficiency\")}\n    sectionId=\"privacy-efficiency\"\n/>\n\nall requests to the backend are anonymous and all information about potential file tunnels is encrypted.\nwe have a strict zero log policy and don't store or track *anything* about individual people.\n\nif a request requires additional processing, such as remuxing or transcoding, cobalt processes media\ndirectly on your device. this ensures best efficiency and privacy.\n\nif your device doesn't support local processing, then server-based live processing is used instead.\nin this scenario, processed media is streamed directly to client, without ever being stored on server's disk.\n\nyou can [enable forced tunneling](/settings/privacy#tunnel) to boost privacy even further.\nwhen enabled, cobalt will tunnel all downloaded files, not just those that require it.\nno one will know where you download something from, even your network provider.\nall they'll see is that you're using a cobalt instance.\n</section>\n\n<section id=\"community\">\n<SectionHeading\n    title={$t(\"about.heading.community\")}\n    sectionId=\"community\"\n/>\n\ncobalt is used by countless artists, educators, and content creators to do what they love.\nwe're always on the line with our community and work together to make cobalt even more useful.\nfeel free to [join the conversation](/about/community)!\n\nwe believe that the future of the internet is open, which is why cobalt is\n[source first](https://sourcefirst.com/) and [easily self-hostable]({docs.instanceHosting}).\n\nif your friend hosts a processing instance, just ask them for a domain and [add it in instance settings](/settings/instances#community).\n\nyou can check the source code and contribute [on github]({contacts.github}) at any time.\nwe welcome all contributions and suggestions!\n</section>\n"
  },
  {
    "path": "web/i18n/en/about/privacy.md",
    "content": "<script lang=\"ts\">\n    import env from \"$lib/env\";\n    import { t } from \"$lib/i18n/translations\";\n\n    import SectionHeading from \"$components/misc/SectionHeading.svelte\";\n</script>\n\n<section id=\"general\">\n<SectionHeading\n    title={$t(\"about.heading.general\")}\n    sectionId=\"general\"\n/>\n\ncobalt's privacy policy is simple: we don't collect or store anything about you.\nwhat you do is solely your business, not ours or anyone else's.\n\nthese terms are applicable only when using the official cobalt instance.\nin other cases, you may need to contact the instance hoster for accurate info.\n</section>\n\n<section id=\"local\">\n<SectionHeading\n    title={$t(\"about.heading.local\")}\n    sectionId=\"local\"\n/>\n\ntools that use on-device processing work offline, locally,\nand never send any processed data anywhere.\nthey are explicitly marked as such whenever applicable.\n</section>\n\n<section id=\"saving\">\n<SectionHeading\n    title={$t(\"about.heading.saving\")}\n    sectionId=\"saving\"\n/>\n\nwhen using saving functionality, cobalt may need to proxy or remux/transcode files.\nif that's the case, then a temporary tunnel is created for this purpose\nand minimal required information about the media is stored for 90 seconds.\n\non an unmodified & official cobalt instance,\n**all tunnel data is encrypted with a key that only the end user has access to**.\n\nencrypted tunnel data may include:\n- origin service's name.\n- original URLs for media files.\n- internal arguments needed to differentiate between types of processing.\n- minimal file metadata (generated filename, title, author, creation year, copyright info).\n- minimal information about the original request that may be used in case of an URL failure during the tunnelling process.\n\nthis data is irreversibly purged from server's RAM after 90 seconds.\nno one has access to cached tunnel data, even instance owners,\nas long as cobalt's source code is not modified.\n\nmedia data from tunnels is never stored/cached anywhere.\neverything is processed live, even during remuxing and transcoding.\ncobalt tunnels function like an anonymous proxy.\n\nif your device supports local processing,\nthen encrypted tunnel info includes way less info, because it's returned to client instead.\n\nsee the [related source code on github](https://github.com/imputnet/cobalt/tree/main/api/src/stream)\nto learn more about how it works.\n</section>\n\n<section id=\"encryption\">\n<SectionHeading\n    title={$t(\"about.heading.encryption\")}\n    sectionId=\"encryption\"\n/>\n\ntemporarily stored tunnel data is encrypted using the AES-256 standard.\ndecryption keys are only included in the access link and never logged/cached/stored anywhere.\nonly the end user has access to the link & encryption keys.\nkeys are generated uniquely for each requested tunnel.\n</section>\n\n{#if env.PLAUSIBLE_ENABLED}\n<section id=\"plausible\">\n<SectionHeading\n    title={$t(\"about.heading.plausible\")}\n    sectionId=\"plausible\"\n/>\n\nwe use [plausible](https://plausible.io/) to get an approximate number\nof active cobalt users, fully anonymously. no identifiable information about\nyou or your requests is ever stored. all data is anonymized and aggregated.\nwe self-host and manage the [plausible instance](https://{env.PLAUSIBLE_HOST}/) that cobalt uses.\n\nplausible doesn't use cookies and is fully compliant with GDPR, CCPA, and PECR.\n\nif you wish to opt out of anonymous analytics, you can do it in [privacy settings](/settings/privacy#analytics).\nif you opt out, the plausible script will not be loaded at all.\n\n[learn more about plausible's dedication to privacy](https://plausible.io/privacy-focused-web-analytics).\n</section>\n{/if}\n\n<section id=\"cloudflare\">\n<SectionHeading\n    title={$t(\"about.heading.cloudflare\")}\n    sectionId=\"cloudflare\"\n/>\n\nwe use cloudflare services for:\n- ddos & abuse protection.\n- bot protection (cloudflare turnstile).\n- hosting & deploying the statically rendered web app (cloudflare workers).\n\nall of these are required to provide the best experience for everyone.\ncloudflare is the most private & reliable provider for all mentioned solutions that we know of.\n\ncloudflare is fully compliant with GDPR and HIPAA.\n\n[learn more about cloudflare's dedication to privacy](https://www.cloudflare.com/trust-hub/privacy-and-data-protection/).\n</section>\n"
  },
  {
    "path": "web/i18n/en/about/terms.md",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n    import SectionHeading from \"$components/misc/SectionHeading.svelte\";\n</script>\n\n<section id=\"general\">\n<SectionHeading\n    title={$t(\"about.heading.general\")}\n    sectionId=\"general\"\n/>\n\nthese terms are applicable only when using the official cobalt instance.\nin other cases, you may need to contact the instance hoster for accurate info.\n</section>\n\n<section id=\"saving\">\n<SectionHeading\n    title={$t(\"about.heading.saving\")}\n    sectionId=\"saving\"\n/>\n\nsaving functionality simplifies downloading content from the internet\nand we take zero liability for what the saved content is used for.\n\nprocessing servers operate like advanced proxies and don't ever write any requested content to disk.\neverything is handled in RAM and permanently purged once the tunnel is completed.\nwe have no downloading logs and cannot identify anyone.\n\nyou can learn more about how tunnels work in [privacy policy](/about/privacy).\n</section>\n\n<section id=\"responsibility\">\n<SectionHeading\n    title={$t(\"about.heading.responsibility\")}\n    sectionId=\"responsibility\"\n/>\n\nyou (end user) are responsible for what you do with our tools, how you use and distribute resulting content.\nplease be mindful when using content of others and always credit original creators.\nmake sure you don't violate any terms or licenses.\n\nwhen used in educational purposes, always cite sources and credit original creators.\n\nfair use and credits benefit everyone.\n</section>\n\n<section id=\"abuse\">\n<SectionHeading\n    title={$t(\"about.heading.abuse\")}\n    sectionId=\"abuse\"\n/>\n\nwe have no way of detecting abusive behavior automatically because cobalt is fully anonymous.\nhowever, you can report such activities to us via email and we'll do our best to comply manually: abuse[at]imput.net\n\n**this email is not intended for user support, you will not get a response if your concern is not related to abuse.**\n\nif you're experiencing issues, you can reach out for support via any preferred method on [the community page](/about/community).\n</section>\n"
  },
  {
    "path": "web/i18n/en/about.json",
    "content": "{\n    \"page.general\": \"what's cobalt?\",\n\n    \"page.community\": \"community & support\",\n\n    \"page.privacy\": \"privacy policy\",\n    \"page.terms\": \"terms and ethics\",\n    \"page.credits\": \"thanks & licenses\",\n\n    \"heading.general\": \"general terms\",\n    \"heading.licenses\": \"licenses\",\n    \"heading.summary\": \"best way to save what you love\",\n    \"heading.privacy_efficiency\": \"leading privacy & efficiency\",\n    \"heading.community\": \"open community\",\n    \"heading.local\": \"local processing\",\n    \"heading.saving\": \"saving\",\n    \"heading.encryption\": \"encryption\",\n    \"heading.plausible\": \"anonymous traffic analytics\",\n    \"heading.cloudflare\": \"web privacy & security\",\n    \"heading.responsibility\": \"user responsibilities\",\n    \"heading.abuse\": \"reporting abuse\",\n    \"heading.motivation\": \"motivation\",\n    \"heading.testers\": \"beta testers\",\n    \"heading.partners\": \"partners\",\n\n    \"support.github\": \"check out cobalt's source code, contribute changes, or report issues\",\n    \"support.discord\": \"chat with the community and developers about cobalt or ask for help\",\n    \"support.twitter\": \"follow cobalt's updates and development on your twitter timeline\",\n    \"support.telegram\": \"stay up to date with latest cobalt updates via a telegram channel\",\n    \"support.bluesky\": \"follow cobalt's updates and development on your bluesky feed\",\n\n    \"support.description.issue\": \"if you want to report a bug or some other recurring issue, please do it on github.\",\n    \"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.\",\n    \"support.description.best-effort\": \"all support is best effort and not guaranteed, a reply might take some time.\"\n}\n"
  },
  {
    "path": "web/i18n/en/button.json",
    "content": "{\n    \"gotit\": \"got it\",\n    \"cancel\": \"cancel\",\n    \"reset\": \"reset\",\n    \"done\": \"done\",\n    \"download.audio\": \"download audio\",\n    \"download\": \"download\",\n    \"share\": \"share\",\n    \"copy\": \"copy\",\n    \"copy.section\": \"copy the section link\",\n    \"copied\": \"copied\",\n    \"import\": \"import\",\n    \"continue\": \"continue\",\n    \"star\": \"star\",\n    \"follow\": \"follow\",\n    \"save\": \"save\",\n    \"export\": \"export\",\n    \"yes\": \"yes\",\n    \"no\": \"no\",\n    \"clear\": \"clear\",\n    \"show_input\": \"show input\",\n    \"hide_input\": \"hide input\",\n    \"restore_input\": \"restore input\",\n    \"clear_input\": \"clear input\",\n    \"clear_cache\": \"clear cache\",\n    \"remove\": \"remove\",\n    \"retry\": \"retry\",\n    \"delete\": \"delete\"\n}\n"
  },
  {
    "path": "web/i18n/en/dialog.json",
    "content": "{\n    \"reset_settings.title\": \"reset all settings?\",\n    \"reset_settings.body\": \"are you sure you want to reset all settings? this action is immediate and irreversible.\",\n\n    \"picker.title\": \"select what to save\",\n    \"picker.description.desktop\": \"click an item to save it. images can also be saved via the right click menu.\",\n    \"picker.description.phone\": \"press an item to save it. images can also be saved with a long press.\",\n    \"picker.description.ios\": \"press an item to save it with a shortcut. images can also be saved with a long press.\",\n\n    \"saving.title\": \"choose how to save\",\n    \"saving.blocked\": \"cobalt tried opening the file in a new tab, but your browser blocked it. you can allow pop-ups for cobalt to prevent this from happening next time.\",\n    \"saving.timeout\": \"cobalt tried saving the file automatically, but your browser stopped it. you have to select a preferred method manually.\",\n\n    \"safety.title\": \"important safety notice\",\n\n    \"import.body\": \"importing unknown or corrupted files may unexpectedly alter or break cobalt functionality. only import files that you've personally exported and haven't modified. if you were asked to import this file by someone - don't do it.\\n\\nwe are not responsible for any harm caused by importing unknown setting files.\",\n\n    \"safety.custom_instance.body\": \"custom instances can potentially pose privacy & safety risks.\\n\\nbad instances can:\\n1. redirect you away from cobalt and try to scam you.\\n2. log all information about your requests, store it forever, and use it to track you.\\n3. serve you malicious files (such as malware).\\n4. force you to watch ads, or make you pay for downloading.\\n\\nafter this point, we can't protect you. please be mindful of what instances to use and always trust your gut. if anything feels off, come back to this page, reset the custom instance, and report it to us on github.\",\n\n    \"clear_cache.title\": \"clear all cache?\",\n    \"clear_cache.body\": \"all files from the processing queue will be removed and on-device features will take longer to load. this action is immediate and irreversible.\"\n}\n"
  },
  {
    "path": "web/i18n/en/donate.json",
    "content": "{\n    \"banner.title\": \"Support a safe\\nand open Internet\",\n    \"banner.subtitle\": \"donate to imput or share the\\njoy of cobalt with a friend\",\n\n    \"body.motivation\": \"cobalt helps producers, educators, video makers, and many others to do what they love. it's a different kind of service that is made with love, not for profit.\",\n    \"body.no_bullshit\": \"we believe that the internet doesn't have to be scary, which is why cobalt will never have ads or other kinds of malicious content. it's a promise that we firmly stand by. everything we do is built with privacy, accessibility, and ease of use in mind, making cobalt available for everyone.\",\n    \"body.keep_going\": \"if you found cobalt useful, please consider supporting our work! you can help us by making a donation or sharing cobalt with a friend. every donation is highly appreciated and helps us keep working on cobalt and other projects.\",\n\n    \"card.once\": \"one-time donation\",\n    \"card.recurring\": \"recurring donation\",\n    \"card.custom\": \"custom amount (from $2)\",\n\n    \"card.processor\": \"via {{value}}\",\n\n    \"card.option.5\": \"cup of coffee\",\n    \"card.option.10\": \"full size pizza\",\n    \"card.option.15\": \"full lunch\",\n    \"card.option.30\": \"lunch for two\",\n    \"card.option.50\": \"10kg of cat food\",\n    \"card.option.100\": \"one year of domains\",\n    \"card.option.200\": \"air fryer\",\n    \"card.option.500\": \"fancy office chair\",\n    \"card.option.1599\": \"base macbook pro\",\n    \"card.option.4900\": \"10,000 apples\",\n    \"card.option.7398\": \"maxed out macbook pro\",\n    \"card.option.8629\": \"a small plot of land\",\n    \"card.option.9433\": \"luxury hot tub\",\n\n    \"card.custom.submit\": \"donate custom amount\",\n\n    \"share.title\": \"share cobalt with a friend\",\n\n    \"alternative.title\": \"alternative ways to donate\",\n\n    \"alt.copy\": \"{{ value }}. crypto wallet address. press to copy.\",\n    \"alt.open\": \"{{ value }}. press to open.\"\n}\n"
  },
  {
    "path": "web/i18n/en/error/api.json",
    "content": "{\n    \"auth.jwt.missing\": \"couldn't authenticate with the processing instance because the access token is missing. try again in a few seconds or reload the page!\",\n    \"auth.jwt.invalid\": \"couldn't authenticate with the processing instance because the access token is invalid. try again in a few seconds or reload the page!\",\n    \"auth.turnstile.missing\": \"couldn't authenticate with the processing instance because the captcha solution is missing. try again in a few seconds or reload the page!\",\n    \"auth.turnstile.invalid\": \"couldn't authenticate with the processing instance because the captcha solution is invalid. try again in a few seconds or reload the page!\",\n\n    \"auth.key.missing\": \"an access key is required to use this processing instance but it's missing. add it in instance settings!\",\n    \"auth.key.not_api_key\": \"an access key is required to use this processing instance but it's missing. add it in instance settings!\",\n\n    \"auth.key.invalid\": \"the access key is invalid. reset it in instance settings and use a proper one!\",\n    \"auth.key.not_found\": \"the access key you used couldn't be found. are you sure this instance has your key?\",\n    \"auth.key.invalid_ip\": \"your ip address couldn't be parsed. something went very wrong. report this issue!\",\n    \"auth.key.ip_not_allowed\": \"your ip address is not allowed to use this access key. use a different instance or network!\",\n    \"auth.key.ua_not_allowed\": \"your user agent is not allowed to use this access key. use a different client or device!\",\n\n    \"unreachable\": \"couldn't connect to the processing instance. check your internet connection and try again!\",\n    \"timed_out\": \"the processing instance took too long to respond. it may be overwhelmed at the moment, try again in a few seconds!\",\n    \"rate_exceeded\": \"you're making too many requests. try again in {{ limit }} seconds.\",\n    \"capacity\": \"cobalt is at capacity and can't process your request at the moment. try again in a few seconds!\",\n\n    \"generic\": \"something went wrong and i couldn't get anything for you, try again in a few seconds. if the issue sticks, please report it!\",\n    \"unknown_response\": \"couldn't read the response from the processing instance. this is probably caused by the web app being out of date. reload the app and try again!\",\n    \"invalid_body\": \"couldn't send the request to the processing instance. this is probably caused by the web app being out of date. reload the app and try again!\",\n\n    \"service.unsupported\": \"this service is not supported yet. have you pasted the right link?\",\n    \"service.disabled\": \"this service is generally supported by cobalt, but it's disabled on this processing instance. try a link from another service!\",\n    \"service.audio_not_supported\": \"this service doesn't support audio extraction. try a link from another service!\",\n\n    \"link.invalid\": \"your link is invalid or this service is not supported yet. have you pasted the right link?\",\n    \"link.unsupported\": \"{{ service }} is supported, but i couldn't recognize your link. have you pasted the right one?\",\n\n    \"fetch.fail\": \"something went wrong when fetching info from {{ service }} and i couldn't get anything for you. if this issue sticks, please report it!\",\n    \"fetch.critical\": \"the {{ service }} module returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!\",\n    \"fetch.critical.core\": \"one of the core modules returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!\",\n    \"fetch.empty\": \"couldn't find any media that i could download for you. are you sure you pasted the right link?\",\n    \"fetch.rate\": \"the processing instance got rate limited by {{ service }}. try again in a few seconds!\",\n    \"fetch.short_link\": \"couldn't get info from the short link. are you sure it works? if it does and you still get this error, please report this issue!\",\n\n    \"content.too_long\": \"media you requested is too long. the duration limit on this instance is {{ limit }} minutes. try something shorter instead!\",\n\n    \"content.video.unavailable\": \"i can't access this video. it may be restricted on {{ service }}'s side. try a different link!\",\n    \"content.video.live\": \"this video is currently live, so i can't download it yet. wait for the live stream to finish and try again!\",\n    \"content.video.private\": \"this video is private, so i can't access it. change its visibility or try another one!\",\n    \"content.video.age\": \"this video is age-restricted, so i can't access it anonymously. try again or try a different link!\",\n    \"content.video.region\": \"this video is region locked, and the processing instance is in a different location. try a different link!\",\n\n    \"content.region\": \"this content is region locked, and the processing instance is in a different location. try a different link!\",\n    \"content.paid\": \"this content requires purchase. cobalt can't download paid content. try a different link!\",\n\n    \"content.post.unavailable\": \"couldn't find anything about this post. its visibility may be limited or it may not exist. make sure your link works and try again in a few seconds!\",\n    \"content.post.private\": \"couldn't get anything about this post because it's from a private account. try a different link!\",\n    \"content.post.age\": \"this post is age-restricted, so i can't access it anonymously. try again or try a different link!\",\n\n    \"youtube.no_matching_format\": \"youtube didn't return any acceptable formats. cobalt may not support them or they're re-encoding on youtube's side. try again a bit later, but if this issue sticks, please report it!\",\n    \"youtube.decipher\": \"youtube updated its decipher algorithm and i couldn't extract the info about the video. try again in a few seconds, but if this issue sticks, please report it!\",\n    \"youtube.login\": \"couldn't get this video because youtube asked the processing instance to prove that it's not a bot. try again in a few seconds, but if it still doesn't work, please report this issue!\",\n    \"youtube.token_expired\": \"couldn't get this video because the youtube token expired and wasn't refreshed. try again in a few seconds, but if it still doesn't work, please report this issue!\",\n    \"youtube.no_hls_streams\": \"couldn't find any matching HLS streams for this video. try downloading it without HLS!\",\n    \"youtube.api_error\": \"youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!\",\n    \"youtube.temporary_disabled\": \"youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\\n\\nwe apologize for the inconvenience and are doing our best to restore this functionality. check cobalt's socials or github for timely updates!\",\n    \"youtube.drm\": \"this youtube video is protected by widevine DRM, so i can't download it. try a different link!\",\n    \"youtube.no_session_tokens\": \"couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!\"\n}\n"
  },
  {
    "path": "web/i18n/en/error/queue.json",
    "content": "{\n    \"no_final_file\": \"no final file output\",\n    \"worker_didnt_start\": \"couldn't start a processing worker\",\n\n    \"generic_error\": \"processing worker crashed, see console for details\",\n\n    \"fetch.crashed\": \"fetch worker crashed, see console for details\",\n    \"fetch.bad_response\": \"couldn't access the file tunnel\",\n    \"fetch.no_file_reader\": \"couldn't write a file to cache\",\n    \"fetch.empty_tunnel\": \"file tunnel is empty, try again in a few minutes\",\n    \"fetch.corrupted_file\": \"file wasn't downloaded fully, try again\",\n    \"fetch.network_error\": \"downloading was interrupted by a network issue\",\n\n    \"ffmpeg.probe_failed\": \"couldn't probe this file, it may be unsupported or corrupted\",\n    \"ffmpeg.out_of_memory\": \"not enough available memory, can't continue\",\n    \"ffmpeg.no_input_format\": \"the file's format isn't supported\",\n    \"ffmpeg.no_input_type\": \"the file's type isn't supported\",\n    \"ffmpeg.crashed\": \"ffmpeg worker crashed, see console for details\",\n    \"ffmpeg.no_render\": \"ffmpeg render is empty, something very odd happened\",\n    \"ffmpeg.no_args\": \"ffmpeg worker didn't get required arguments\",\n    \"ffmpeg.no_audio_channel\": \"this video has no audio track, nothing to do\"\n}\n"
  },
  {
    "path": "web/i18n/en/error.json",
    "content": "{\n    \"import.no_data\": \"there are no settings to load from this file. are you sure it's the right one?\",\n    \"import.invalid\": \"this file doesn't have valid cobalt settings to import. are you sure it's the right one?\",\n    \"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 }}\",\n\n    \"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?\",\n\n    \"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.\",\n\n    \"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!\"\n}\n"
  },
  {
    "path": "web/i18n/en/general.json",
    "content": "{\n    \"cobalt\": \"cobalt\",\n    \"meowbalt\": \"meowbalt\",\n    \"beta\": \"beta\",\n\n    \"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!\"\n}\n"
  },
  {
    "path": "web/i18n/en/notification.json",
    "content": "{\n    \"update.title\": \"update is available!\",\n    \"update.subtext\": \"press to reload\"\n}\n"
  },
  {
    "path": "web/i18n/en/queue.json",
    "content": "{\n    \"title\": \"processing queue\",\n    \"stub\": \"nothing here yet, just the two of us.\\ntry downloading something!\",\n\n    \"state.waiting\": \"queued\",\n    \"state.retrying\": \"retrying\",\n    \"state.starting\": \"starting\",\n\n    \"state.starting.fetch\": \"starting downloading\",\n    \"state.starting.remux\": \"starting remuxing\",\n    \"state.starting.encode\": \"starting transcoding\",\n\n    \"state.running.remux\": \"remuxing\",\n    \"state.running.fetch\": \"downloading\",\n    \"state.running.encode\": \"transcoding\"\n}\n"
  },
  {
    "path": "web/i18n/en/receiver.json",
    "content": "{\n    \"title\": \"drag or select a file\",\n    \"title.multiple\": \"drag or select files\",\n    \"title.drop\": \"drop the file here!\",\n    \"title.drop.multiple\": \"drop the files here!\",\n    \"accept\": \"supported formats: {{ formats }}.\"\n}\n"
  },
  {
    "path": "web/i18n/en/remux.json",
    "content": "{\n    \"bullet.purpose.title\": \"what does remux do?\",\n    \"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.\",\n    \"bullet.explainer.title\": \"how does it work?\",\n    \"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.\",\n    \"bullet.privacy.title\": \"on-device processing\",\n    \"bullet.privacy.description\": \"cobalt remuxes files locally. files never leave your device, so processing is nearly instant.\"\n}\n"
  },
  {
    "path": "web/i18n/en/save.json",
    "content": "{\n    \"paste\": \"paste\",\n    \"paste.long\": \"paste and download\",\n    \"auto\": \"auto\",\n    \"audio\": \"audio\",\n    \"mute\": \"mute\",\n    \"input.placeholder\": \"paste the link here\",\n    \"terms.note.agreement\": \"by continuing, you agree to\",\n    \"terms.note.link\": \"terms and ethics of use\",\n    \"services.title\": \"supported services\",\n    \"services.title_show\": \"show supported services\",\n    \"services.title_hide\": \"hide supported services\",\n    \"services.disclaimer\": \"support for a service does not imply affiliation, endorsement, or any form of support other than technical compatibility.\",\n\n    \"tutorial.title\": \"how to save on ios?\",\n    \"tutorial.intro\": \"to save media conveniently on ios, you'll need to use a companion siri shortcut from the share sheet.\",\n    \"tutorial.step.1\": \"add companion siri shortcuts:\",\n    \"tutorial.step.2\": \"press the \\\"share\\\" button in cobalt's saving dialog.\",\n    \"tutorial.step.3\": \"select the respective shortcut in the share sheet.\",\n    \"tutorial.outro\": \"these shortcuts will work only from the cobalt app, sharing links from other apps will not work.\",\n    \"tutorial.shortcut.photos\": \"to photos\",\n    \"tutorial.shortcut.files\": \"to files\",\n\n    \"label.community_instance\": \"community instance\",\n\n    \"tooltip.captcha\": \"cloudflare turnstile is checking if you're not a bot, please wait!\"\n}\n"
  },
  {
    "path": "web/i18n/en/settings.json",
    "content": "{\n    \"page.appearance\": \"appearance\",\n    \"page.privacy\": \"privacy\",\n    \"page.video\": \"video\",\n    \"page.audio\": \"audio\",\n    \"page.metadata\": \"metadata\",\n    \"page.advanced\": \"advanced\",\n    \"page.debug\": \"info for nerds\",\n    \"page.instances\": \"instances\",\n    \"page.local\": \"local processing\",\n    \"page.accessibility\": \"accessibility\",\n\n    \"theme\": \"theme\",\n    \"theme.auto\": \"auto\",\n    \"theme.light\": \"light\",\n    \"theme.dark\": \"dark\",\n    \"theme.description\": \"auto theme switches between light and dark themes depending on your device's display mode.\",\n\n    \"video.quality\": \"video quality\",\n    \"video.quality.max\": \"8k+\",\n    \"video.quality.2160\": \"4k\",\n    \"video.quality.1440\": \"1440p\",\n    \"video.quality.1080\": \"1080p\",\n    \"video.quality.720\": \"720p\",\n    \"video.quality.480\": \"480p\",\n    \"video.quality.360\": \"360p\",\n    \"video.quality.240\": \"240p\",\n    \"video.quality.144\": \"144p\",\n    \"video.quality.description\": \"if preferred video quality isn't available, next best is picked instead.\",\n\n    \"video.youtube.codec\": \"preferred youtube video codec\",\n    \"video.youtube.codec.description\": \"h264: best compatibility, average quality. max quality is 1080p. \\nav1: best quality and efficiency. supports 8k & HDR. \\nvp9: same quality as av1, but file is ~2x bigger. supports 4k & HDR.\\n\\nav1 and vp9 aren't widely supported, you might have to use additional software to play/edit them. cobalt picks next best codec if preferred one isn't available.\",\n\n    \"video.youtube.container\": \"youtube file container\",\n    \"video.youtube.container.description\": \"when \\\"auto\\\" is selected, cobalt will pick the best container automatically depending on selected codec: mp4 for h264; webm for vp9/av1.\",\n\n    \"video.youtube.hls\": \"youtube hls formats\",\n    \"video.youtube.hls.title\": \"prefer hls for video & audio\",\n    \"video.youtube.hls.description\": \"only h264 and vp9 codecs are available in this mode. original audio codec is aac, it's re-encoded for compatibility, audio quality may be slightly worse than the non-HLS counterpart.\\n\\nthis option is experimental, it may go away or change in the future.\",\n\n    \"video.twitter.gif\": \"twitter/x\",\n    \"video.twitter.gif.title\": \"convert looping videos to GIF\",\n    \"video.twitter.gif.description\": \"GIF conversion is inefficient, converted file may be obnoxiously big and low quality.\",\n\n    \"video.h265\": \"high efficiency video codec\",\n    \"video.h265.title\": \"allow h265 for videos\",\n    \"video.h265.description\": \"allows downloading videos from platforms like tiktok and xiaohongshu in higher quality at cost of compatibility.\",\n\n    \"audio.format\": \"audio format\",\n    \"audio.format.best\": \"best\",\n    \"audio.format.mp3\": \"mp3\",\n    \"audio.format.ogg\": \"ogg\",\n    \"audio.format.wav\": \"wav\",\n    \"audio.format.opus\": \"opus\",\n    \"audio.format.description\": \"all formats but \\\"best\\\" are converted from the source format, there will be some quality loss. when \\\"best\\\" format is selected, the audio is kept in its original format whenever possible.\",\n\n    \"audio.bitrate\": \"audio bitrate\",\n    \"audio.bitrate.kbps\": \"kb/s\",\n    \"audio.bitrate.description\": \"bitrate is applied only when converting audio to a lossy format. cobalt can't improve the source audio quality, so choosing a bitrate over 128kbps may inflate the file size with no audible difference. perceived quality may vary by format.\",\n\n    \"audio.youtube.dub\": \"youtube audio track\",\n    \"audio.youtube.dub.title\": \"preferred dub language\",\n    \"audio.youtube.dub.description\": \"cobalt will use a dubbed audio track for selected language if it's available. if not, original will be used instead.\",\n    \"youtube.dub.original\": \"original\",\n\n    \"subtitles\": \"subtitles\",\n    \"subtitles.title\": \"preferred subtitle language\",\n    \"subtitles.description\": \"cobalt will add subtitles to the downloaded file in the preferred language if they're available.\\n\\nsome services don't have a language selection, and if that's the case, cobalt will add the only available subtitle track if you have any language selected.\",\n    \"subtitles.none\": \"none\",\n\n    \"audio.youtube.better_audio\": \"youtube audio quality\",\n    \"audio.youtube.better_audio.title\": \"prefer better quality\",\n    \"audio.youtube.better_audio.description\": \"cobalt will try to pick highest quality audio in audio mode. it may not be available depending on youtube's response, current traffic, and server status. custom instances may not support this option.\",\n\n    \"audio.tiktok.original\": \"tiktok\",\n    \"audio.tiktok.original.title\": \"download original sound\",\n    \"audio.tiktok.original.description\": \"cobalt will download the sound from the video without any changes by the post's author.\",\n\n    \"metadata.filename\": \"filename style\",\n    \"metadata.filename.classic\": \"classic\",\n    \"metadata.filename.basic\": \"basic\",\n    \"metadata.filename.pretty\": \"pretty\",\n    \"metadata.filename.nerdy\": \"nerdy\",\n    \"metadata.filename.description\": \"filename style will only be used for files tunneled by cobalt. some services don't support filename styles other than classic.\",\n\n    \"metadata.filename.preview.video\": \"Video Title - Video Author\",\n    \"metadata.filename.preview.audio\": \"Audio Title - Audio Author\",\n    \"filename.preview_desc.video\": \"video file preview\",\n    \"filename.preview_desc.audio\": \"audio file preview\",\n\n    \"metadata.file\": \"file metadata\",\n    \"metadata.disable.title\": \"disable file metadata\",\n    \"metadata.disable.description\": \"title, artist, and other info will not be added to the file.\",\n\n    \"saving.title\": \"saving method\",\n    \"saving.ask\": \"ask\",\n    \"saving.download\": \"download\",\n    \"saving.share\": \"share\",\n    \"saving.copy\": \"copy\",\n    \"saving.description\": \"preferred way of saving the file or link from cobalt. if preferred method is unavailable or something goes wrong, cobalt will ask you what to do next.\",\n\n    \"accessibility.visual\": \"visual\",\n    \"accessibility.haptics\": \"haptics\",\n    \"accessibility.behavior\": \"behavior\",\n\n    \"accessibility.transparency.title\": \"reduce visual transparency\",\n    \"accessibility.transparency.description\": \"transparency of surfaces will be reduced and all blur effects will be disabled. may also improve ui performance on less powerful devices.\",\n    \"accessibility.motion.title\": \"reduce motion\",\n    \"accessibility.motion.description\": \"animations and transitions will be disabled whenever possible.\",\n    \"accessibility.haptics.title\": \"disable haptics\",\n    \"accessibility.haptics.description\": \"all haptic effects will be disabled.\",\n    \"accessibility.auto_queue.title\": \"don't open the queue automatically\",\n    \"accessibility.auto_queue.description\": \"the processing queue will not be opened automatically whenever a new item is added to it. progress will still be displayed and you will still be able to open it manually.\",\n\n    \"language\": \"language\",\n    \"language.auto.title\": \"automatic selection\",\n    \"language.auto.description\": \"cobalt will use your browser's default language if translation is available. if not, english will be used instead.\",\n    \"language.preferred.title\": \"preferred language\",\n    \"language.preferred.description\": \"this language will be used when automatic selection is disabled. any text that isn't translated will be displayed in english.\\n\\nsome languages use community-sourced translations, they may be inaccurate or incomplete.\",\n\n    \"privacy.analytics\": \"anonymous traffic analytics\",\n    \"privacy.analytics.title\": \"don't contribute to analytics\",\n    \"privacy.analytics.description\": \"anonymous traffic analytics are needed to get an approximate number of active cobalt users. no identifiable information about you is ever stored. all processed data is anonymized and aggregated.\\n\\nwe use a self-hosted plausible instance that doesn't use cookies and is fully compliant with GDPR, CCPA, and PECR.\",\n    \"privacy.analytics.learnmore\": \"learn more about plausible's dedication to privacy.\",\n\n    \"privacy.tunnel\": \"tunneling\",\n    \"privacy.tunnel.title\": \"always tunnel files\",\n    \"privacy.tunnel.description\": \"cobalt will hide your ip address, browser info, and bypass local network restrictions. when enabled, files will also have readable filenames that otherwise would be gibberish.\",\n\n    \"advanced.debug\": \"debug\",\n    \"advanced.debug.title\": \"enable features for nerds\",\n    \"advanced.debug.description\": \"gives you easy access to app info that can be useful for debugging. enabling this does not affect functionality of cobalt in any way.\",\n\n    \"processing.community\": \"community instances\",\n    \"processing.enable_custom.title\": \"use a custom processing server\",\n    \"processing.enable_custom.description\": \"cobalt will use a custom processing instance if you choose to. even though cobalt has some security measures in place, we are not responsible for any damages done via a community instance, as we have no control over them.\\n\\nplease be mindful of what instances you use and make sure they're hosted by people you trust.\",\n\n    \"processing.access_key\": \"instance access key\",\n    \"processing.access_key.title\": \"use an instance access key\",\n    \"processing.access_key.description\": \"cobalt will use this key to make requests to the processing instance instead of other authentication methods. make sure the instance supports api keys!\",\n\n    \"processing.custom_instance.input.alt_text\": \"custom instance domain\",\n    \"processing.access_key.input.alt_text\": \"u-u-i-d access key\",\n\n    \"advanced.settings_data\": \"settings data\",\n    \"advanced.local_storage\": \"local storage\",\n\n    \"local.saving\": \"local media processing\",\n    \"local.saving.description\": \"when downloading media, remuxing and transcoding will be done on-device instead of the cloud. you'll see detailed progress in the processing queue.\\n\\ndisabled: local processing will not be used. processing instances can enforce local processing, so this option may not have effect.\\npreferred: media that requires extra processing will be downloaded through the processing queue, but the rest of media will be downloaded by your browser's download manager.\\nforced: all media will always be proxied and downloaded through the processing queue.\\n\\nexclusive on-device features are not affected by this setting, they always run locally.\",\n    \"local.saving.disabled\": \"disabled\",\n    \"local.saving.preferred\": \"preferred\",\n    \"local.saving.forced\": \"forced\",\n\n    \"local.webcodecs\": \"webcodecs\",\n    \"local.webcodecs.title\": \"use webcodecs for on-device processing\",\n    \"local.webcodecs.description\": \"when decoding or encoding files, cobalt will try to use webcodecs. this feature allows for GPU-accelerated processing of media files, meaning that all decoding & encoding will be way faster.\\n\\navailability and stability of this feature depends on your device's and browser's capabilities. stuff might break or not work properly.\",\n\n    \"tabs\": \"navigation\",\n    \"tabs.hide_remux\": \"hide the remux tab\",\n    \"tabs.hide_remux.description\": \"if you don't use the remux tool, you can hide it from the navigation bar.\"\n}\n"
  },
  {
    "path": "web/i18n/en/tabs.json",
    "content": "{\n    \"save\": \"save\",\n    \"settings\": \"settings\",\n    \"updates\": \"updates\",\n    \"donate\": \"donate\",\n    \"about\": \"about\",\n    \"remux\": \"remux\"\n}\n"
  },
  {
    "path": "web/i18n/en/updates.json",
    "content": "{\n    \"button.next\": \"go to older changelog ({{ value }})\",\n    \"button.previous\": \"go to newer changelog ({{ value }})\"\n}\n"
  },
  {
    "path": "web/i18n/languages.json",
    "content": "{\n    \"en\": \"english\",\n    \"ru\": \"русский\"\n}\n"
  },
  {
    "path": "web/i18n/ru/a11y/dialog.json",
    "content": "{\n    \"picker.item.photo\": \"превью фотографии\",\n    \"picker.item.video\": \"превью видео\",\n    \"picker.item.gif\": \"превью gif\"\n}\n"
  },
  {
    "path": "web/i18n/ru/a11y/donate.json",
    "content": "{\n    \"share.qr.expand\": \"qr-код. нажми, чтобы развернуть.\",\n    \"share.qr.collapse\": \"развёрнутый qr-код. нажми, чтобы свернуть.\"\n}\n"
  },
  {
    "path": "web/i18n/ru/a11y/general.json",
    "content": "{\n    \"back\": \"назад\"\n}\n"
  },
  {
    "path": "web/i18n/ru/a11y/queue.json",
    "content": "{\n    \"status.completed\": \"очередь обработки. все задачи завершены.\",\n    \"status.ongoing\": \"очередь обработки. есть текущие задачи.\",\n    \"status.default\": \"очередь обработки\"\n}\n"
  },
  {
    "path": "web/i18n/ru/a11y/save.json",
    "content": "{\n    \"link_area\": \"поле ввода ссылки\",\n    \"clear_input\": \"очистить поле ввода\",\n    \"download\": \"скачать\",\n    \"download.think\": \"обрабатываю ссылку...\",\n    \"download.check\": \"проверяю загрузку...\",\n    \"download.done\": \"загрузка завершена\",\n    \"download.error\": \"ошибка загрузки\",\n    \"link_area.turnstile\": \"поле ввода ссылки. проверяю, что ты не робот.\",\n    \"tutorial.shortcut.photos\": \"добавить команду \\\"в фото\\\"\",\n    \"tutorial.shortcut.files\": \"добавить команду \\\"в файлы\\\"\"\n}\n"
  },
  {
    "path": "web/i18n/ru/a11y/tabs.json",
    "content": "{\n    \"tab_panel\": \"панель вкладок\"\n}\n"
  },
  {
    "path": "web/i18n/ru/about/credits.md",
    "content": "<script lang=\"ts\">\n    import { contacts, docs, partners } from \"$lib/env\";\n    import { t } from \"$lib/i18n/translations\";\n\n    import SectionHeading from \"$components/misc/SectionHeading.svelte\";\n    import BetaTesters from \"$components/misc/BetaTesters.svelte\";\n</script>\n\n<section id=\"imput\">\n<SectionHeading\n    title=\"imput\"\n    sectionId=\"imput\"\n/>\n\nкобальт сделан с любовью и заботой руками [imput](https://imput.net/) ❤️\n\nмы маленькая команда из двух человек, но мы очень усердно работаем, чтобы делать\nклассный софт, который приносит пользу всем. если тебе нравится то, что мы\nделаем, поддержи нас на [странице донатов](/donate)!\n</section>\n\n<section id=\"testers\">\n<SectionHeading\n    title={$t(\"about.heading.testers\")}\n    sectionId=\"testers\"\n/>\n\nогромное спасибо нашим тестерам за то, что они тестировали обновления заранее и\nследили за их стабильностью. они ещё помогли нам выпустить cobalt 10!\n<BetaTesters />\n\nвсе ссылки внешние и ведут на их личные сайты или соцсети.\n</section>\n\n<section id=\"partners\">\n<SectionHeading\n    title={$t(\"about.heading.partners\")}\n    sectionId=\"partners\"\n/>\n\nчасть инфраструктуры кобальта предоставлена нашим давним партнёром,\n[royalehosting.net]({partners.royalehosting})!\n</section>\n\n<section id=\"meowbalt\">\n<SectionHeading\n    title={$t(\"general.meowbalt\")}\n    sectionId=\"meowbalt\"\n/>\n\nмяубальт — это шустрый маскот кобальта, очень выразительный кот, который любит\nбыстрый интернет.\n\nвесь потрясающий арт мяубальта, который ты видишь в кобальте, был сделан\n[GlitchyPSI](https://glitchypsi.xyz/). он ещё и оригинальный создатель этого\nперсонажа.\n\nimput владеет юридическими правами на дизайн персонажа мяубальта, но не на\nконкретные арты, которые были созданы GlitchyPSI.\n\nмы любим мяубальта, поэтому мы вынуждены установить пару правил, чтобы его\nзащитить:\n- ты не можешь использовать дизайн персонажа мяубальта ни в какой форме, кроме\n  фанарта.\n- ты не можешь использовать дизайн или арты мяубальта в коммерческих целях.\n- ты не можешь использовать дизайн или арты мяубальта в своих проектах.\n- ты не можешь использовать или изменять работы GlitchyPSI с мяубальтом ни в\n  каком виде.\n\nесли ты нарисуешь фанарт мяубальта, не стесняйся делиться им в [нашем\nдискорд-сервере](/about/community), мы с нетерпением ждём!\n</section>\n\n<section id=\"licenses\">\n<SectionHeading\n    title={$t(\"about.heading.licenses\")}\n    sectionId=\"licenses\"\n/>\n\nкод api (сервера обработки) кобальта — open source и распространяется по\nлицензии [AGPL-3.0]({docs.apiLicense}).\n\nкод фронтенда кобальта — [source first](https://sourcefirst.com/) и\nраспространяется по лицензии [CC-BY-NC-SA 4.0]({docs.webLicense}).\n\nнам пришлось сделать фронтенд source first, чтобы грифтеры не наживались на\nнашем труде и не создавали вредоносные клоны для обмана людей и порче нашей\nрепутации. кроме коммерческого использования, у этого типа лицензии те же\nпринципы, что и у многих open source лицензий.\n\nмы используем много опенсорсных библиотек, но также создаём и распространяем\nсвои собственные. полный список зависимостей можно посмотреть на\n[github]({contacts.github})!\n</section>\n"
  },
  {
    "path": "web/i18n/ru/about/general.md",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n    import { contacts, docs } from \"$lib/env\";\n\n    import SectionHeading from \"$components/misc/SectionHeading.svelte\";\n</script>\n\n<section id=\"summary\">\n<SectionHeading\n    title={$t(\"about.heading.summary\")}\n    sectionId=\"summary\"\n/>\n\nкобальт помогает сохранять что угодно с твоих любимых сайтов: видео, аудио, фото\nили гифки. просто вставь ссылку и вперёд!\n\nникакой рекламы, трекеров, платных подписок и прочей ерунды. просто удобное\nвеб-приложение, которое работает где угодно и когда угодно.\n</section>\n\n<section id=\"motivation\">\n<SectionHeading\n    title={$t(\"about.heading.motivation\")}\n    sectionId=\"motivation\"\n/>\n\nкобальт был создан для всеобщего блага, чтобы защитить людей от рекламы и\nвредоносных программ, которые навязывают альтернативные загрузчики. мы верим,\nчто лучший софт — безопасный, открытый и доступный. все проекты imput следуют\nэтим принципам.\n</section>\n\n<section id=\"privacy-efficiency\">\n<SectionHeading\n    title={$t(\"about.heading.privacy_efficiency\")}\n    sectionId=\"privacy-efficiency\"\n/>\n\nвсе запросы к бэкенду анонимны, и вся инфа о потенциальных файловых туннелях\nзашифрована. у нас строгая политика нулевых логов, мы *никогда* не храним\nидентифицирующую инфу о людях и никого не отслеживаем.\n\nесли запрос требует дополнительной обработки, например ремукса или\nтранскодирования, то кобальт обрабатывает медиафайлы прямо на твоём устройстве.\nэто обеспечивает максимальную эффективность и приватность.\n\nесли твоё устройство не поддерживает локальную обработку, то вместо неё\nиспользуется серверная обработка в реальном времени. в этом сценарии\nобработанные медиаданные передаются напрямую клиенту, никогда не сохраняясь на\nдиске сервера.\n\nты можешь [включить принудительное туннелирование](/settings/privacy#tunnel),\nчтобы ещё сильнее повысить приватность. когда оно включено, кобальт будет\nтуннелировать все скачиваемые файлы, а не только те, которым это необходимо.\nникто не узнает, откуда и что ты скачиваешь, даже твой провайдер. всё, что они\nувидят, это то, что ты используешь инстанс кобальта.\n</section>\n\n<section id=\"community\">\n<SectionHeading\n    title={$t(\"about.heading.community\")}\n    sectionId=\"community\"\n/>\n\nкобальт используют бесчисленные артисты, преподаватели и прочие создатели\nконтента, чтобы заниматься любимым делом. мы всегда на связи с нашим сообществом\nи работаем вместе, чтобы делать кобальт ещё полезнее. не стесняйся\n[присоединиться к разговору](/about/community)!\n\nмы верим, что будущее интернета — открытое и свободное, поэтому кобальт\nопубликован с [открытым исходным кодом](https://sourcefirst.com/) и его можно\nлегко [захостить самому]({docs.instanceHosting}).\n\nесли твой друг хостит инстанс обработки, просто попроси у него домен и [добавь\nего в настройках инстанса](/settings/instances#community).\n\nты можешь посмотреть исходный код и внести свой вклад [на\ngithub]({contacts.github}) в любое время. мы рады любым предложениям и помощи!\n</section>\n"
  },
  {
    "path": "web/i18n/ru/about/privacy.md",
    "content": "<script lang=\"ts\">\n    import env from \"$lib/env\";\n    import { t } from \"$lib/i18n/translations\";\n\n    import SectionHeading from \"$components/misc/SectionHeading.svelte\";\n</script>\n\n<section id=\"general\">\n<SectionHeading\n    title={$t(\"about.heading.general\")}\n    sectionId=\"general\"\n/>\n\nполитика конфиденциальности кобальта проста: мы ничего не собираем и не храним о\nтебе. то, что ты делаешь, — это исключительно твоё дело, а не наше или чьё-либо\nещё.\n\nэти условия применяются только при использовании официального инстанса кобальта.\nв других случаях, возможно, придётся обратиться к хостеру инстанса за точной\nинформацией.\n</section>\n\n<section id=\"local\">\n<SectionHeading\n    title={$t(\"about.heading.local\")}\n    sectionId=\"local\"\n/>\n\nинструменты, которые используют обработку на устройстве, работают офлайн,\nлокально и никогда никуда не отправляют обработанные данные. они явно помечены\nкак таковые, когда это применимо.\n</section>\n\n<section id=\"saving\">\n<SectionHeading\n    title={$t(\"about.heading.saving\")}\n    sectionId=\"saving\"\n/>\n\nпри использовании функции сохранения, кобальту может понадобиться проксировать\nили ремуксировать/транскодировать файлы. если это так, то для этой цели\nсоздаётся временный туннель, и минимально необходимая информация о медиа\nхранится в течение 90 секунд.\n\nна неизменённом и официальном инстансе кобальта **все данные туннеля шифруются\nключом, к которому имеет доступ только конечный пользователь**.\n\nзашифрованные данные туннеля могут включать:\n- название исходного сервиса.\n- исходные ссылки на медиафайлы.\n- необходимые внутренние аргументы для различения типов обработки.\n- ключевые метаданные файла (сгенерированное имя, заголовок, автор, год\n  создания, данные об авторских правах).\n- минимальная информация об исходном запросе, которая может быть использована\n  для восстановления туннеля после ошибки ссылки во время скачивания.\n\nэти данные безвозвратно удаляются из оперативной памяти сервера через 90 секунд.\nникто не имеет доступа к кэшированным данным туннеля, даже владельцы инстансов,\nесли исходный код кобальта не изменён.\n\nмедиаданные из туннелей нигде не хранятся/кэшируются. всё обрабатывается в\nреальном времени, даже при ремуксинге и транскодировании. туннели кобальта\nработают как анонимный прокси.\n\nесли твоё устройство поддерживает локальную обработку, то зашифрованный туннель\nсодержит намного меньше информации, потому что она возвращается клиенту.\n\nсмотри [соответствующий исходный код на\ngithub](https://github.com/imputnet/cobalt/tree/main/api/src/stream), чтобы\nузнать больше о том, как это работает.\n</section>\n\n<section id=\"encryption\">\n<SectionHeading\n    title={$t(\"about.heading.encryption\")}\n    sectionId=\"encryption\"\n/>\n\nвременно хранящиеся данные туннеля шифруются с использованием стандарта AES-256.\nключи расшифровки включены только в ссылку доступа и никогда не\nлогируются/кэшируются/хранятся где-либо. только конечный пользователь имеет\nдоступ к ссылке и ключам шифрования. ключи генерируются уникально для каждого\nзапрошенного туннеля.\n</section>\n\n{#if env.PLAUSIBLE_ENABLED}\n<section id=\"plausible\">\n<SectionHeading\n    title={$t(\"about.heading.plausible\")}\n    sectionId=\"plausible\"\n/>\n\nмы используем [plausible](https://plausible.io/), чтобы знать приблизительное\nчисло активных пользователей кобальта, полностью анонимно. никакая\nидентифицирующая информация о тебе или твоих запросах никогда не хранится. все\nданные анонимизированы и агрегированы. мы сами хостим и управляем [инстансом\nplausible](https://{env.PLAUSIBLE_HOST}/), который использует кобальт.\n\nplausible не использует куки и полностью соответствует GDPR, CCPA и PECR.\n\nесли ты хочешь отказаться от анонимной аналитики, то это можно сделать в\n[настройках приватности](/settings/privacy#analytics). после отказа скрипт\nplausible не будет загружаться.\n\n[узнай больше о преданности plausible к\nприватности](https://plausible.io/privacy-focused-web-analytics).\n</section>\n{/if}\n\n<section id=\"cloudflare\">\n<SectionHeading\n    title={$t(\"about.heading.cloudflare\")}\n    sectionId=\"cloudflare\"\n/>\n\nмы используем сервисы cloudflare для:\n- защиты от ddos и абьюза.\n- защиты от ботов (cloudflare turnstile).\n- хостинга и деплоя статического веб-приложения (cloudflare workers).\n\nвсё это необходимо для обеспечения лучшего опыта для всех. cloudflare — наиболее\nприватный и надёжный провайдер всех упомянутых решений из всех известных нам\nпровайдеров.\n\ncloudflare полностью соответствует требованиям GDPR и HIPAA.\n\n[узнай больше о преданности cloudflare к\nприватности](https://www.cloudflare.com/trust-hub/privacy-and-data-protection/).\n</section>\n"
  },
  {
    "path": "web/i18n/ru/about/terms.md",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n    import SectionHeading from \"$components/misc/SectionHeading.svelte\";\n</script>\n\n<section id=\"general\">\n<SectionHeading\n    title={$t(\"about.heading.general\")}\n    sectionId=\"general\"\n/>\n\nэти условия применяются только при использовании официального инстанса кобальта.\nв других случаях, возможно, придётся обратиться к хостеру инстанса за точной\nинформацией.\n</section>\n\n<section id=\"saving\">\n<SectionHeading\n    title={$t(\"about.heading.saving\")}\n    sectionId=\"saving\"\n/>\n\nфункция сохранения упрощает скачивание контента из интернета, и мы не несём\nникакой ответственности за то, как будет использоваться сохранённый контент.\n\nсерверы обработки работают как продвинутые прокси и никогда не записывают\nзапрошенный контент на диск. всё происходит в оперативной памяти и полностью\nудаляется после завершения туннеля. у нас нет логов загрузок, и мы не можем\nникого идентифицировать.\n\nподробнее о том, как работают туннели, можно узнать в [политике\nконфиденциальности](/about/privacy).\n</section>\n\n<section id=\"responsibility\">\n<SectionHeading\n    title={$t(\"about.heading.responsibility\")}\n    sectionId=\"responsibility\"\n/>\n\nты (конечный пользователь) несёшь ответственность за то, что делаешь с нашими\nинструментами, как используешь и распространяешь полученный контент. пожалуйста,\nуважай чужой труд и всегда указывай авторов. убедись, что ты не нарушаешь\nникаких условий или лицензий.\n\nпри использовании в образовательных целях всегда ссылайся на источники и\nуказывай авторов.\n\nдобросовестное использование и указание авторства приносят пользу всем.\n</section>\n\n<section id=\"abuse\">\n<SectionHeading\n    title={$t(\"about.heading.abuse\")}\n    sectionId=\"abuse\"\n/>\n\nу нас нет возможности автоматически выявлять злоупотребления, так как кобальт\nполностью анонимен. однако, есть возможность сообщить нам о такой деятельности\nпо почте, и мы сделаем всё возможное, чтобы принять нужные меры вручную:\nabuse[at]imput.net\n\n**этот адрес не предназначен для поддержки пользователей. ты не получишь ответ,\nесли твой запрос не связан со злоупотреблениями.**\n\nесли у тебя возникли проблемы с работой кобальта, то ты можешь обратиться за\nпомощью любым удобным способом на [странице поддержки и\nсообщества](/about/community).\n</section>\n"
  },
  {
    "path": "web/i18n/ru/about.json",
    "content": "{\n    \"page.general\": \"что такое кобальт?\",\n    \"heading.general\": \"общие условия\",\n    \"heading.saving\": \"скачивание\",\n    \"heading.encryption\": \"шифрование\",\n    \"heading.abuse\": \"сообщение о злоупотреблении\",\n    \"heading.motivation\": \"мотивация\",\n    \"heading.licenses\": \"лицензии\",\n    \"heading.summary\": \"лучший способ сохранять то, что ты любишь\",\n    \"page.community\": \"сообщество и поддержка\",\n    \"page.privacy\": \"конфиденциальность\",\n    \"page.terms\": \"условия и этика\",\n    \"page.credits\": \"благодарности и лицензии\",\n    \"heading.testers\": \"бета-тестеры\",\n    \"heading.community\": \"открытое сообщество\",\n    \"heading.local\": \"обработка на устройстве\",\n    \"heading.plausible\": \"анонимная аналитика трафика\",\n    \"heading.cloudflare\": \"веб-приватность и безопасность\",\n    \"heading.responsibility\": \"ответственности пользователя\",\n    \"support.github\": \"смотри исходный код кобальта, вноси свой вклад или сообщай о проблемах\",\n    \"support.discord\": \"общайся с сообществом и разработчиками кобальта или попроси о помощи\",\n    \"support.description.issue\": \"если ты хочешь сообщить о баге или какой-то другой повторяющейся проблеме, то делай это на github.\",\n    \"support.description.help\": \"используй discord для любых других вопросов. чётко опиши проблему в #cobalt-support, иначе никто не сможет тебе помочь.\",\n    \"support.twitter\": \"следи за обновлениями и разработкой кобальта в своей ленте твиттера\",\n    \"support.telegram\": \"следи за обновлениями кобальта в телеграм-канале\",\n    \"support.description.best-effort\": \"вся поддержка осуществляется по мере возможности и не гарантируется, а ответ может занять какое-то время.\",\n    \"heading.privacy_efficiency\": \"лучшая приватность и эффективность\",\n    \"heading.partners\": \"партнёры\",\n    \"support.bluesky\": \"следи за обновлениями и разработкой кобальта в своей ленте bluesky\"\n}\n"
  },
  {
    "path": "web/i18n/ru/button.json",
    "content": "{\n    \"download.audio\": \"скачать аудио\",\n    \"import\": \"импортировать\",\n    \"copied\": \"скопировано\",\n    \"copy\": \"скопировать\",\n    \"share\": \"поделиться\",\n    \"download\": \"скачать\",\n    \"no\": \"нет\",\n    \"yes\": \"да\",\n    \"save\": \"скачать\",\n    \"continue\": \"продолжить\",\n    \"done\": \"готово\",\n    \"reset\": \"сбросить\",\n    \"cancel\": \"отменить\",\n    \"export\": \"экспортировать\",\n    \"gotit\": \"понятно\",\n    \"copy.section\": \"скопировать ссылку на раздел\",\n    \"clear_input\": \"очистить поле ввода\",\n    \"show_input\": \"показать ввод\",\n    \"hide_input\": \"скрыть ввод\",\n    \"restore_input\": \"восстановить ввод\",\n    \"clear\": \"очистить\",\n    \"remove\": \"убрать\",\n    \"clear_cache\": \"очистить кэш\",\n    \"retry\": \"повторить\",\n    \"delete\": \"удалить\"\n}\n"
  },
  {
    "path": "web/i18n/ru/dialog.json",
    "content": "{\n    \"picker.title\": \"что сохранить?\",\n    \"saving.title\": \"как сохранить?\",\n    \"saving.timeout\": \"кобальт попытался сохранить файл автоматически, но твой браузер остановил это. выбери способ вручную.\",\n    \"reset_settings.title\": \"сбросить все настройки?\",\n    \"reset_settings.body\": \"ты точно хочешь сбросить все настройки? это действие мгновенное и необратимое.\",\n    \"picker.description.phone\": \"нажми на то, что хочешь скачать. картинки также можно скачать долгим нажатием.\",\n    \"picker.description.desktop\": \"кликни на то, что хочешь скачать. картинки также можно скачать через контекстное меню.\",\n    \"picker.description.ios\": \"нажми на то, что хочешь скачать через команду siri. картинки также можно скачать долгим нажатием.\",\n    \"saving.blocked\": \"кобальт попытался открыть файл в новой вкладке, но твой браузер заблокировал это. разреши всплывающие окна для кобальта, чтобы избежать этого в следующий раз.\",\n    \"clear_cache.title\": \"очистить весь кэш?\",\n    \"import.body\": \"импорт неизвестных или повреждённых файлов может неожиданно изменить или сломать работу кобальта. импортируй только те файлы, которые ты экспортировал сам и не изменял. если кто-то попросил тебя импортировать этот файл — не делай этого.\\n\\nмы не несём ответственности за любой вред, причинённый импортом неизвестных файлов настроек.\",\n    \"safety.custom_instance.body\": \"сторонние инстансы могут быть опасны для твоей приватности и безопасности.\\n\\nвредоносные инстансы могут:\\n1. перенаправлять тебя с кобальта и пытаться обмануть.\\n2. записывать всю информацию о твоих запросах, хранить её вечно и использовать для слежки за тобой.\\n3. скачивать вредоносные файлы (например, вирусы).\\n4. заставлять тебя смотреть рекламу или платить за скачивание.\\n\\nпосле этого момента мы не сможем тебя защитить. пожалуйста, будь осторожен с выбором инстанса и всегда доверяй своей интуиции. если что-то кажется странным, то вернись на эту страницу, сбрось пользовательский инстанс и сообщи нам об этом на github.\",\n    \"clear_cache.body\": \"все файлы из очереди обработки будут удалены и локальные фичи займут больше времени на загрузку. это действие мгновенное и необратимое.\",\n    \"safety.title\": \"важное предупреждение о безопасности\"\n}\n"
  },
  {
    "path": "web/i18n/ru/donate.json",
    "content": "{\n    \"card.once\": \"одноразовый донат\",\n    \"card.option.30\": \"обед для двоих\",\n    \"body.no_bullshit\": \"мы считаем, что интернет не должен быть страшным. поэтому в кобальте никогда не будет рекламы или другого вредоносного контента. это обещание, за которым мы стоим горой. всё, что мы делаем, создаётся с учётом конфиденциальности, доступности и простоты использования, что делает кобальт доступным для всех.\",\n    \"card.custom\": \"своя сумма (от $2)\",\n    \"card.processor\": \"через {{value}}\",\n    \"card.option.5\": \"чашка кофе\",\n    \"card.option.50\": \"10кг кошачьего корма\",\n    \"card.option.1599\": \"базовый макбук\",\n    \"card.option.4900\": \"10,000 яблок\",\n    \"share.title\": \"поделись кобальтом с другом\",\n    \"alternative.title\": \"альтернативные способы доната\",\n    \"alt.copy\": \"{{ value }}. адрес криптокошелька. нажми, чтобы скопировать.\",\n    \"alt.open\": \"{{ value }}. нажми, чтобы открыть.\",\n    \"body.motivation\": \"кобальт помогает продюсерам, преподавателям, видеомейкерам и многим другим заниматься тем, что они любят. это особый сервис, создающийся с любовью, а не ради прибыли.\",\n    \"body.keep_going\": \"если кобальт помог тебе, пожалуйста, подумай над тем, чтобы поддержать нашу работу! ты можешь поддержать нас донатом, либо поделившись кобальтом с другом. каждый донат очень ценится и помогает нам продолжать работу над кобальтом и другими проектами.\",\n    \"card.recurring\": \"регулярный донат\",\n    \"card.option.10\": \"большая пицца\",\n    \"card.option.15\": \"полный обед\",\n    \"card.custom.submit\": \"своя сумма\",\n    \"banner.title\": \"Поддержи безопасный\\nи открытый Интернет\",\n    \"banner.subtitle\": \"поддержи imput или поделись\\nкобальтом с другом\",\n    \"card.option.100\": \"один год доменов\",\n    \"card.option.200\": \"аэрогриль\",\n    \"card.option.500\": \"крутое офисное кресло\",\n    \"card.option.7398\": \"флагманский макбук\",\n    \"card.option.8629\": \"маленький земельный участок\",\n    \"card.option.9433\": \"джакузи класса люкс\"\n}\n"
  },
  {
    "path": "web/i18n/ru/error/api.json",
    "content": "{\n    \"auth.jwt.invalid\": \"не удалось пройти аутентификацию с инстансом обработки, потому что токен доступа недействителен. попробуй ещё раз через пару секунд или перезагрузи страницу!\",\n    \"auth.turnstile.invalid\": \"не удалось пройти аутентификацию с инстансом обработки, потому что решение капчи недействительно. попробуй ещё раз через пару секунд или перезагрузи страницу!\",\n    \"auth.key.not_api_key\": \"для использования этого инстанса нужен ключ доступа, но его нет. добавь его в настройках!\",\n    \"auth.key.invalid\": \"ключ доступа недействителен. сбрось его в настройках инстанса и используй правильный!\",\n    \"auth.key.ua_not_allowed\": \"ты не можешь использовать этот ключ доступа с текущего юзер агента. попробуй другой клиент или устройство!\",\n    \"unreachable\": \"не удалось подключиться к инстансу обработки. проверь своё интернет-соединение и попробуй ещё раз!\",\n    \"rate_exceeded\": \"ты делаешь слишком много запросов. попробуй снова через {{ limit }}с.\",\n    \"capacity\": \"кобальт сейчас перегружен и не может обработать твой запрос. попробуй ещё раз через пару секунд!\",\n    \"service.unsupported\": \"этот сервис ещё не поддерживается. ты уверен, что вставил правильную ссылку?\",\n    \"service.audio_not_supported\": \"этот сервис не поддерживает извлечение аудио. попробуй ссылку с другого сервиса!\",\n    \"link.invalid\": \"твоя ссылка недействительна или этот сервис ещё не поддерживается. ты точно вставил правильную ссылку?\",\n    \"fetch.fail\": \"что-то пошло не так при получении инфы из {{ service }}, и я ничего не смог для тебя достать. если эта проблема не исчезнет, пожалуйста, сообщи о ней!\",\n    \"auth.jwt.missing\": \"не удалось пройти аутентификацию с инстансом обработки, потому что отсутствует токен доступа. попробуй ещё раз через пару секунд или перезагрузи страницу!\",\n    \"auth.key.missing\": \"для использования этого инстанса нужен ключ доступа, но его нет. добавь его в настройках!\",\n    \"generic\": \"что-то пошло не так, и я не смог ничего найти для тебя. попробуй ещё раз через пару секунд. если проблема останется, пожалуйста, сообщи об этом!\",\n    \"auth.turnstile.missing\": \"не удалось пройти аутентификацию с инстансом обработки, потому что отсутствует решение капчи. попробуй ещё раз через пару секунд или перезагрузи страницу!\",\n    \"unknown_response\": \"не удалось прочитать ответ от инстанса обработки. скорее всего, причина в том, что веб-приложение устарело. перезагрузи его и попробуй снова!\",\n    \"auth.key.not_found\": \"использованный тобой ключ доступа не найден. ты уверен, что у этого инстанса есть твой ключ?\",\n    \"invalid_body\": \"не удалось отправить запрос на инстанс обработки. скорее всего, причина в том, что веб-приложение устарело. перезагрузи его и попробуй снова!\",\n    \"auth.key.invalid_ip\": \"не удалось распарсить твой ip-адрес. что-то пошло совсем не так, пожалуйста, сообщи об этой ошибке!\",\n    \"auth.key.ip_not_allowed\": \"ты не можешь использовать этот ключ доступа с текущего ip-адреса. попробуй другой инстанс или сеть!\",\n    \"timed_out\": \"инстанс обработки слишком долго не отвечал. возможно, он сейчас перегружен, попробуй ещё раз через пару секунд!\",\n    \"service.disabled\": \"этот сервис обычно поддерживается кобальтом, но он отключён на этом инстансе. попробуй ссылку с другого сервиса!\",\n    \"link.unsupported\": \"{{ service }} поддерживается, но я не смог распознать твою ссылку. ты точно вставил правильную?\",\n    \"fetch.critical\": \"модуль {{ service }} вернул ошибку, которую я не узнаю. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!\",\n    \"content.too_long\": \"запрошенное медиа слишком длинное. лимит длительности на этом инстансе — {{ limit }}мин. попробуй что-нибудь покороче!\",\n    \"content.video.unavailable\": \"я не могу получить доступ к этому видео. оно может быть ограничено со стороны {{ service }}. попробуй другую ссылку!\",\n    \"content.video.private\": \"это видео приватное, поэтому я не могу получить к нему доступ. измени его видимость или попробуй другое!\",\n    \"content.video.region\": \"это видео ограничено по региону, а инстанс обработки находится в другом месте. попробуй другую ссылку!\",\n    \"content.paid\": \"этот контент требует покупки. кобальт не может скачивать платный контент. попробуй другую ссылку!\",\n    \"content.post.private\": \"не удалось получить инфу об этом посте, потому что он от закрытого аккаунта. попробуй другую ссылку!\",\n    \"youtube.token_expired\": \"не удалось получить это видео, потому что токен youtube истёк и не был обновлён. попробуй ещё раз через пару секунд, но если так и не заработает, пожалуйста, сообщи об этой проблеме!\",\n    \"youtube.no_hls_streams\": \"не удалось найти ни одного подходящего HLS-потока для этого видео. попробуй скачать его без HLS!\",\n    \"youtube.api_error\": \"youtube что-то обновил в своём api, и я не смог получить инфу об этом видео. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!\",\n    \"youtube.drm\": \"это youtube-видео защищено widevine DRM, так что я не могу его скачать. попробуй другую ссылку!\",\n    \"fetch.rate\": \"{{ service }} ограничил частоту запросов от инстанса обработки. попробуй ещё раз через пару секунд!\",\n    \"youtube.temporary_disabled\": \"скачивание с youtube временно отключено из-за ограничений со стороны youtube. мы уже ищем способы их обойти.\\n\\nприносим извинения за неудобства и делаем всё возможное, чтобы восстановить эту функциональность. следи за обновлениями в соцсетях или на github!\",\n    \"content.video.age\": \"это видео ограничено по возрасту, поэтому я не могу получить его анонимно. попробуй ещё раз или попробуй другую ссылку!\",\n    \"content.region\": \"этот контент ограничен по региону, а инстанс обработки находится в другом месте. попробуй другую ссылку!\",\n    \"youtube.no_matching_format\": \"youtube не вернул ни одного подходящего формата. возможно, кобальт их не поддерживает или же они перекодируются на стороне youtube. попробуй ещё раз чуть позже, а если проблема останется, сообщи о ней!\",\n    \"youtube.no_session_tokens\": \"не удалось получить необходимые токены сессии для ютуба. это может быть вызвано ограничением со стороны ютуба. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!\",\n    \"youtube.decipher\": \"youtube обновил свой алгоритм расшифровки, и из-за этого мне не удалось получить информацию о видео. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!\",\n    \"fetch.short_link\": \"не удалось получить инфу по короткой ссылке. ты уверен, что она работает? если да, а ты всё равно видишь эту ошибку, пожалуйста, сообщи о ней!\",\n    \"fetch.empty\": \"не смог найти медиа, которое я мог бы скачать для тебя. ты уверен, что вставил правильную ссылку?\",\n    \"content.post.age\": \"этот пост ограничен по возрасту, поэтому я не могу получить его анонимно. попробуй ещё раз или попробуй другую ссылку!\",\n    \"youtube.login\": \"не удалось получить это видео, потому что youtube попросил доказать, что инстанс обработки — не бот. попробуй ещё раз через пару секунд, но если так и не заработает, пожалуйста, сообщи об этой проблеме!\",\n    \"content.video.live\": \"это видео сейчас идёт в прямом эфире, поэтому я ещё не могу его скачать. подожди, пока стрим закончится, и попробуй снова!\",\n    \"content.post.unavailable\": \"не удалось ничего найти об этом посте. его видимость может быть ограничена или он может не существовать. убедись, что твоя ссылка работает, и попробуй снова через пару секунд!\",\n    \"fetch.critical.core\": \"один из основных модулей выдал ошибку, которую я не узнаю. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!\"\n}\n"
  },
  {
    "path": "web/i18n/ru/error/queue.json",
    "content": "{\n    \"fetch.no_file_reader\": \"не смог записать файл в кэш\",\n    \"worker_didnt_start\": \"не смог запустить воркер обработки\",\n    \"ffmpeg.probe_failed\": \"не удалось проверить этот файл, возможно, он повреждён или не поддерживается\",\n    \"fetch.network_error\": \"скачивание было прервано из-за проблем с сетью\",\n    \"no_final_file\": \"финальный файл пропал\",\n    \"fetch.corrupted_file\": \"файл был скачан не полностью, попробуй ещё раз\",\n    \"fetch.crashed\": \"воркер скачивания вылетел, смотри детали в консоли\",\n    \"fetch.bad_response\": \"не смог получить туннель файла\",\n    \"fetch.empty_tunnel\": \"туннель файла пустой, попробуй ещё раз через несколько минут\",\n    \"ffmpeg.no_input_type\": \"тип этого файла не поддерживается\",\n    \"ffmpeg.crashed\": \"воркер ffmpeg вылетел, смотри детали в консоли\",\n    \"ffmpeg.no_input_format\": \"формат этого файла не поддерживается\",\n    \"ffmpeg.out_of_memory\": \"не хватает памяти, не могу продолжить\",\n    \"ffmpeg.no_render\": \"рендер ffmpeg пустой, произошло что-то очень странное\",\n    \"ffmpeg.no_args\": \"воркер ffmpeg не получил нужные аргументы\",\n    \"generic_error\": \"воркер обработки вылетел, смотри детали в консоли\",\n    \"ffmpeg.no_audio_channel\": \"у этого видео нет аудиодорожки, ничего нельзя сделать\"\n}\n"
  },
  {
    "path": "web/i18n/ru/error.json",
    "content": "{\n    \"pipeline.missing_response_data\": \"инстанс обработки не ответил с нужной информацией о файле, поэтому я не могу создать задачи для локальной обработки. попробуй ещё раз через несколько секунд и сообщи о проблеме, если она не исчезнет!\",\n    \"captcha_too_long\": \"cloudflare turnstile слишком долго проверяет, что ты не бот. попробуй ещё раз, но если снова появится эта ошибка, то можно попробовать: отключить странные расширения браузера, сменить сеть, использовать другой браузер или проверить устройство на наличие вредоносных программ.\",\n    \"import.invalid\": \"в этом файле нет совместимых настроек кобальта для импорта. ты уверен, что это тот файл?\",\n    \"tunnel.probe\": \"не удалось протестировать этот туннель. возможно, твой браузер или настройки сети блокируют доступ к одному из серверов кобальта. ты уверен, что у тебя нет каких-то странных расширений для браузера?\",\n    \"import.unknown\": \"не удалось загрузить данные из файла. возможно, он повреждён или не того формата. вот ошибка, которую я получил:\\n\\n{{ value }}\",\n    \"import.no_data\": \"из этого файла нечего загружать. ты уверен, что это тот файл?\"\n}\n"
  },
  {
    "path": "web/i18n/ru/general.json",
    "content": "{\n    \"cobalt\": \"кобальт\",\n    \"meowbalt\": \"мяубальт\",\n    \"beta\": \"бета\",\n    \"embed.description\": \"кобальт помогает тебе сохранять то, что ты любишь, без рекламы, трекеров и прочей ерунды. просто вставь ссылку!\"\n}\n"
  },
  {
    "path": "web/i18n/ru/notification.json",
    "content": "{\n    \"update.title\": \"доступно обновление!\",\n    \"update.subtext\": \"нажми, чтобы обновить\"\n}\n"
  },
  {
    "path": "web/i18n/ru/queue.json",
    "content": "{\n    \"state.waiting\": \"в очереди\",\n    \"state.starting.fetch\": \"начинаю скачивание\",\n    \"state.running.remux\": \"ремуксирую\",\n    \"state.retrying\": \"повторяю\",\n    \"state.starting.encode\": \"начинаю транскодирование\",\n    \"title\": \"очередь обработки\",\n    \"state.starting\": \"начинаю\",\n    \"state.starting.remux\": \"начинаю ремуксинг\",\n    \"state.running.fetch\": \"скачиваю\",\n    \"state.running.encode\": \"транскодирую\",\n    \"stub\": \"тут пока что ничего нет, только мы вдвоём.\\nпопробуй скачать что-нибудь!\"\n}\n"
  },
  {
    "path": "web/i18n/ru/receiver.json",
    "content": "{\n    \"accept\": \"поддерживаемые форматы: {{ formats }}.\",\n    \"title\": \"перетащи или выбери файл\",\n    \"title.drop\": \"скинь файл сюда!\",\n    \"title.multiple\": \"перетащи или выбери файлы\",\n    \"title.drop.multiple\": \"скинь файлы сюда!\"\n}\n"
  },
  {
    "path": "web/i18n/ru/remux.json",
    "content": "{\n    \"bullet.purpose.description\": \"ремукс исправляет любые проблемы с файлом, например, отсутствие информации о времени. он помогает повысить совместимость со старыми программами, такими как vegas pro и windows media player.\",\n    \"bullet.purpose.title\": \"что делает ремукс?\",\n    \"bullet.explainer.title\": \"как он работает?\",\n    \"bullet.explainer.description\": \"ремукс берёт существующие данные кодека и копирует их в новый медиаконтейнер. это происходит без потери качества, так как медиаданные не перекодируются.\",\n    \"bullet.privacy.title\": \"локальная обработка\",\n    \"bullet.privacy.description\": \"кобальт ремуксирует файлы локально. файлы никогда не покидают твоё устройство, поэтому обработка происходит практически мгновенно.\"\n}\n"
  },
  {
    "path": "web/i18n/ru/save.json",
    "content": "{\n    \"paste\": \"вставить\",\n    \"paste.long\": \"вставить и скачать\",\n    \"auto\": \"авто\",\n    \"audio\": \"аудио\",\n    \"mute\": \"без звука\",\n    \"input.placeholder\": \"вставь ссылку сюда\",\n    \"terms.note.agreement\": \"продолжая, ты соглашаешься с\",\n    \"terms.note.link\": \"условиями и этикой использования\",\n    \"services.title\": \"поддерживаемые сервисы\",\n    \"services.title_show\": \"показать поддерживаемые сервисы\",\n    \"services.title_hide\": \"скрыть поддерживаемые сервисы\",\n    \"services.disclaimer\": \"поддержка сервиса не означает аффилированность, одобрение или любую другую форму поддержки, кроме технической совместимости.\\n\\nдеятельность владельца facebook и instagram запрещена на территории РФ и признана экстремистской.\",\n    \"tutorial.step.1\": \"добавь команды-компаньоны:\",\n    \"tutorial.step.2\": \"нажми кнопку \\\"поделиться\\\" в диалоге сохранения кобальта.\",\n    \"tutorial.step.3\": \"выбери нужную команду в окне обмена.\",\n    \"tutorial.shortcut.photos\": \"в фото\",\n    \"tutorial.shortcut.files\": \"в файлы\",\n    \"tutorial.title\": \"как сохранить на ios?\",\n    \"tutorial.intro\": \"чтобы удобно сохранять файлы на ios, придётся использовать команду siri в меню обмена.\",\n    \"tutorial.outro\": \"эти команды siri будут работать только из приложения кобальта, использовать их из других приложений не получится.\",\n    \"tooltip.captcha\": \"cloudflare turnstile проверяет, что ты не бот. подожди, пожалуйста!\",\n    \"label.community_instance\": \"инстанс сообщества\"\n}\n"
  },
  {
    "path": "web/i18n/ru/settings.json",
    "content": "{\n    \"theme.auto\": \"авто\",\n    \"theme.light\": \"светлая\",\n    \"audio.bitrate.kbps\": \"кб/с\",\n    \"theme.dark\": \"тёмная\",\n    \"audio.youtube.dub\": \"звуковая дорожка youtube\",\n    \"video.quality.max\": \"8k+\",\n    \"page.video\": \"видео\",\n    \"page.audio\": \"аудио\",\n    \"video.quality.1440\": \"1440p\",\n    \"video.quality.1080\": \"1080p\",\n    \"video.quality.720\": \"720p\",\n    \"video.quality.480\": \"480p\",\n    \"video.quality.360\": \"360p\",\n    \"video.quality.240\": \"240p\",\n    \"video.quality.144\": \"144p\",\n    \"metadata.file\": \"метаданные файла\",\n    \"saving.title\": \"метод сохранения\",\n    \"saving.ask\": \"спросить\",\n    \"saving.download\": \"скачать\",\n    \"saving.share\": \"поделиться\",\n    \"saving.copy\": \"скопировать\",\n    \"language\": \"язык\",\n    \"language.preferred.title\": \"предпочитаемый язык\",\n    \"privacy.analytics\": \"анонимная аналитика трафика\",\n    \"audio.tiktok.original.title\": \"скачивать оригинальный звук\",\n    \"privacy.tunnel\": \"туннелирование\",\n    \"privacy.tunnel.title\": \"всегда туннелировать файлы\",\n    \"audio.format.mp3\": \"mp3\",\n    \"audio.format.ogg\": \"ogg\",\n    \"audio.format.wav\": \"wav\",\n    \"audio.format.opus\": \"opus\",\n    \"page.privacy\": \"приватность\",\n    \"theme\": \"тема\",\n    \"video.quality\": \"качество видео\",\n    \"video.twitter.gif\": \"twitter/x\",\n    \"video.quality.2160\": \"4k\",\n    \"audio.format\": \"формат аудио\",\n    \"audio.bitrate\": \"битрейт аудио\",\n    \"audio.tiktok.original\": \"tiktok\",\n    \"metadata.disable.title\": \"отключить метаданные\",\n    \"language.auto.title\": \"автоматический выбор\",\n    \"metadata.disable.description\": \"название, исполнитель и другая информация не будут добавлены в файл.\",\n    \"language.preferred.description\": \"этот язык будет использоваться когда автоматический выбор отключен. любой непереведённый текст будет отображаться на английском языке.\\n\\nмы используем переводы, предоставленные сообществом. они могут быть неточными или неполными.\",\n    \"audio.youtube.dub.description\": \"cobalt будет использовать дублированную аудиодорожку для выбранного языка, если она доступна. в противном случае будет использоваться оригинальная.\",\n    \"language.auto.description\": \"если доступен перевод, то кобальт будет использовать язык твоего браузера. в ином случае будет использоваться английский.\",\n    \"theme.description\": \"авто тема переключается между светлой и тёмной темой в зависимости от системной темы.\",\n    \"page.debug\": \"инфа для зануд\",\n    \"page.appearance\": \"внешний вид\",\n    \"page.instances\": \"инстансы\",\n    \"page.advanced\": \"продвинутые\",\n    \"page.accessibility\": \"общедоступность\",\n    \"page.metadata\": \"метаданные\",\n    \"page.local\": \"локальная обработка\",\n    \"video.youtube.codec\": \"предпочитаемый кодек для youtube\",\n    \"audio.youtube.dub.title\": \"предпочитаемый язык озвучки\",\n    \"metadata.filename.basic\": \"базовый\",\n    \"video.twitter.gif.title\": \"конвертировать зацикленные видео в GIF\",\n    \"metadata.filename.description\": \"стиль названий файлов используется только для файлов, туннелированных через кобальт. некоторые сервисы поддерживают только классический стиль.\",\n    \"youtube.dub.original\": \"оригинальный\",\n    \"metadata.filename.pretty\": \"красивый\",\n    \"metadata.filename.nerdy\": \"занудный\",\n    \"audio.tiktok.original.description\": \"кобальт будет скачивать оригинальный звук из видео без каких-либо изменений от автора поста.\",\n    \"metadata.filename\": \"стиль названий файлов\",\n    \"metadata.filename.classic\": \"классический\",\n    \"video.twitter.gif.description\": \"GIF конвертация неэффективна, финальный файл может быть огромным и в плохом качестве.\",\n    \"audio.youtube.better_audio.title\": \"предпочитать лучшее качество\",\n    \"audio.format.description\": \"все форматы кроме \\\"лучшего\\\" конвертируются из исходного формата, поэтому возможна небольшая потеря качества. когда выбран \\\"лучший\\\" формат, аудио остаётся в оригинальном формате, если это возможно.\",\n    \"audio.youtube.better_audio.description\": \"кобальт будет пытаться выбрать самое качественное аудио в режиме скачивания аудио. оно может быть недоступно в зависимости от ответа youtube, текущей нагрузки и состояния сервера. на кастомных инстансах эта опция может не поддерживаться.\",\n    \"audio.youtube.better_audio\": \"качество аудио с youtube\",\n    \"video.quality.description\": \"если предпочитаемое качество недоступно, то выбирается следующий лучший вариант.\",\n    \"video.youtube.codec.description\": \"h264: наилучшая совместимость, среднее качество. максимальное качество — 1080p.\\nav1: наилучшее качество и сжатие. поддерживает 8k и HDR.\\nvp9: то же качество, что и у av1, но файл в ~2x больше. поддерживает 4k & HDR.\\n\\nav1 и vp9 не очень широко поддерживаются, возможно придётся использовать дополнительное ПО для их проигрывания/обработки. кобальт выбирает следующий лучший кодек, если предпочитаемый недоступен.\",\n    \"audio.bitrate.description\": \"битрейт применяется только при конвертации аудио в формат с потерями. кобальт не может улучшить качество исходного аудио, поэтому выбор битрейта выше 128 кб/с может увеличить размер файла без заметной разницы в звуке. воспринимаемое качество может различаться в зависимости от формата.\",\n    \"video.h265\": \"high efficiency video codec\",\n    \"video.h265.title\": \"использовать h265 для видео\",\n    \"video.h265.description\": \"позволяет скачивать видео с tiktok и xiaohongshu в более высоком качестве, но с потерей совместимости.\",\n    \"video.youtube.hls\": \"форматы hls для youtube\",\n    \"video.youtube.hls.description\": \"в этом режиме доступны только кодеки h264 и vp9. оригинальный аудио кодек aac перекодируется для совместимости, поэтому качество аудио может быть хуже чем у варианта без HLS.\\n\\nэта функция экспериментальна, поэтому может быть убрана или изменена в будущем.\",\n    \"audio.format.best\": \"лучший\",\n    \"video.youtube.hls.title\": \"предпочитать hls для видео и аудио\",\n    \"metadata.filename.preview.video\": \"Название Видео - Автор Видео\",\n    \"metadata.filename.preview.audio\": \"Название Аудио - Автор Аудио\",\n    \"filename.preview_desc.video\": \"превью видео файла\",\n    \"filename.preview_desc.audio\": \"превью аудио файла\",\n    \"saving.description\": \"предпочтительный способ сохранения файла или ссылки с кобальта. если предпочитаемый метод недоступен или что-то пойдёт не так, кобальт спросит тебя как поступить.\",\n    \"accessibility.transparency.description\": \"уменьшает прозрачность поверхностей и выключает эффекты размытия. также может улучшить работу интерфейса на менее мощных устройствах.\",\n    \"accessibility.transparency.title\": \"уменьшить визуальную прозрачность\",\n    \"accessibility.visual\": \"интерфейс\",\n    \"accessibility.haptics\": \"вибрация\",\n    \"accessibility.behavior\": \"поведение\",\n    \"accessibility.auto_queue.description\": \"очередь обработки не будет открываться автоматически при добавлении новой задачи. прогресс всё равно будет отображаться, и ты всё равно сможешь открыть её вручную.\",\n    \"privacy.analytics.learnmore\": \"узнай больше о преданности plausible к приватности.\",\n    \"accessibility.motion.description\": \"анимации и переходы будут отключены, когда это возможно.\",\n    \"accessibility.haptics.title\": \"отключить вибрацию\",\n    \"accessibility.haptics.description\": \"вся вибрация будет отключена.\",\n    \"accessibility.auto_queue.title\": \"не открывать очередь обработки\",\n    \"privacy.analytics.description\": \"анонимная аналитика трафика нужна, чтобы знать приблизительное количество активных пользователей кобальта. идентифицирующая информация о тебе никогда не сохраняется. все обрабатываемые данные анонимизированы и агрегированы.\\n\\nмы используем собственный инстанс plausible, который не использует куки и полностью соответствует требованиям GDPR, CCPA и PECR.\",\n    \"privacy.tunnel.description\": \"cobalt скроет твой ip адрес, информацию о браузере и обойдёт местные сетевые ограничения. когда включено, у всех файлов будут читаемые названия вместо абракадабры.\",\n    \"accessibility.motion.title\": \"уменьшить движение\",\n    \"privacy.analytics.title\": \"не участвовать в аналитике\",\n    \"advanced.debug\": \"отладка\",\n    \"advanced.debug.description\": \"даёт доступ к странице с различной информацией, которая может быть полезна для отладки. никак не меняет поведение кобальта.\",\n    \"advanced.debug.title\": \"включить функции для зануд\",\n    \"processing.community\": \"инстансы сообщества\",\n    \"processing.enable_custom.description\": \"кобальт будет использовать сторонний инстанс обработки, если ты так решишь. несмотря на то, что у кобальта есть некоторые меры безопасности, мы не несём ответственности за любой ущерб, причинённый сторонним инстансом, так как мы его не контролируем.\\n\\nбудь осторожен с тем, какие инстансы ты используешь, и убедись, что их хостят люди, которым ты доверяешь.\",\n    \"processing.enable_custom.title\": \"использовать сторонний инстанс\",\n    \"local.saving\": \"локальная обработка медиа\",\n    \"local.saving.description\": \"при скачивании медиа, ремуксинг и транскодирование будут выполняться на устройстве, а не в облаке. ты увидишь подробный прогресс в очереди обработки.\\n\\nникогда: локальная обработка не будет использоваться. инстансы обработки могут принудительно включать эту функцию, поэтому эта опция может не иметь эффекта.\\nиногда: медиафайлы, требующие дополнительной обработки, будут загружаться через очередь обработки, но остальные медиафайлы будут загружаться менеджером загрузок твоего браузера.\\nвсегда: все медиафайлы всегда будут проксироваться и загружаться через очередь обработки.\\n\\nэксклюзивные функции на устройстве не зависят от этой настройки, они всегда работают локально.\",\n    \"advanced.settings_data\": \"данные настроек\",\n    \"local.webcodecs.description\": \"при декодировании или кодировании файлов кобальт будет пытаться использовать webcodecs. эта функция позволяет обрабатывать медиафайлы с ускорением на GPU, так что всё декодирование и кодирование будет намного быстрее.\\n\\nдоступность и стабильность этой функции зависят от возможностей твоего устройства и браузера. что-то может сломаться или работать некорректно.\",\n    \"processing.access_key\": \"ключ доступа к инстансу\",\n    \"advanced.local_storage\": \"локальное хранилище\",\n    \"local.webcodecs\": \"webcodecs\",\n    \"local.webcodecs.title\": \"использовать webcodecs для локальной обработки\",\n    \"processing.access_key.title\": \"использовать ключ доступа\",\n    \"processing.custom_instance.input.alt_text\": \"домен стороннего инстанса\",\n    \"tabs\": \"навигация\",\n    \"tabs.hide_remux\": \"скрыть страницу ремукса\",\n    \"tabs.hide_remux.description\": \"если ты не пользуешься ремуксом, то его можно скрыть из панели навигации.\",\n    \"processing.access_key.description\": \"кобальт будет использовать этот ключ для запросов к инстансу обработки вместо других методов аутентификации. убедись, что инстанс поддерживает api ключи!\",\n    \"processing.access_key.input.alt_text\": \"ключ доступа u-u-i-d\",\n    \"video.youtube.container\": \"контейнер файла для youtube\",\n    \"video.youtube.container.description\": \"когда выбран \\\"авто\\\" контейнер, кобальт автоматически подберёт оптимальный контейнер в зависимости от выбранного кодека: mp4 для h264; webm для vp9/av1.\",\n    \"subtitles.description\": \"кобальт добавит субтитры к скачанному файлу на предпочитаемом языке, если они доступны.\\n\\nнекоторые сервисы не имеют выбора языка, и в таком случае кобальт добавит единственную доступную дорожку субтитров, если выбран любой язык.\",\n    \"subtitles\": \"субтитры\",\n    \"subtitles.title\": \"язык субтитров\",\n    \"subtitles.none\": \"никакой\",\n    \"local.saving.disabled\": \"никогда\",\n    \"local.saving.preferred\": \"иногда\",\n    \"local.saving.forced\": \"всегда\"\n}\n"
  },
  {
    "path": "web/i18n/ru/tabs.json",
    "content": "{\n    \"save\": \"скачать\",\n    \"settings\": \"настройки\",\n    \"updates\": \"новости\",\n    \"donate\": \"донаты\",\n    \"about\": \"инфо\",\n    \"remux\": \"ремукс\"\n}\n"
  },
  {
    "path": "web/i18n/ru/updates.json",
    "content": "{\n    \"button.next\": \"перейти к предыдущему обновлению ({{ value }})\",\n    \"button.previous\": \"перейти к следующему обновлению ({{ value }})\"\n}\n"
  },
  {
    "path": "web/package.json",
    "content": "{\n    \"name\": \"@imput/cobalt-web\",\n    \"version\": \"11.3\",\n    \"type\": \"module\",\n    \"private\": true,\n    \"scripts\": {\n        \"dev\": \"vite dev\",\n        \"build\": \"vite build\",\n        \"preview\": \"vite preview\",\n        \"check\": \"svelte-kit sync && svelte-check --tsconfig ./tsconfig.json\",\n        \"check:watch\": \"svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch\"\n    },\n    \"license\": \"CC-BY-NC-SA-4.0\",\n    \"engines\": {\n        \"node\": \">=20\",\n        \"pnpm\": \">=9\"\n    },\n    \"repository\": {\n        \"type\": \"git\",\n        \"url\": \"git+https://github.com/imputnet/cobalt.git\"\n    },\n    \"bugs\": {\n        \"url\": \"https://github.com/imputnet/cobalt/issues\"\n    },\n    \"homepage\": \"https://cobalt.tools/\",\n    \"devDependencies\": {\n        \"@eslint/js\": \"^9.5.0\",\n        \"@fontsource/ibm-plex-mono\": \"^5.0.13\",\n        \"@fontsource/redaction-10\": \"^5.0.2\",\n        \"@imput/libav.js-encode-cli\": \"6.8.7\",\n        \"@imput/libav.js-remux-cli\": \"^6.8.7\",\n        \"@imput/version-info\": \"workspace:^\",\n        \"@sveltejs/adapter-static\": \"^3.0.6\",\n        \"@sveltejs/kit\": \"^2.20.7\",\n        \"@sveltejs/vite-plugin-svelte\": \"^4.0.0\",\n        \"@tabler/icons-svelte\": \"3.6.0\",\n        \"@types/eslint__js\": \"^8.42.3\",\n        \"@types/fluent-ffmpeg\": \"^2.1.25\",\n        \"@types/node\": \"^20.14.10\",\n        \"@vitejs/plugin-basic-ssl\": \"^1.1.0\",\n        \"compare-versions\": \"^6.1.0\",\n        \"dotenv\": \"^16.0.1\",\n        \"eslint\": \"^9.16.0\",\n        \"glob\": \"^11.0.0\",\n        \"mdsvex\": \"^0.11.2\",\n        \"mime\": \"^4.0.4\",\n        \"svelte\": \"^5.0.0\",\n        \"svelte-check\": \"^4.0.0\",\n        \"svelte-preprocess\": \"^6.0.2\",\n        \"svelte-sitemap\": \"2.6.0\",\n        \"sveltekit-i18n\": \"^2.4.2\",\n        \"ts-deepmerge\": \"^7.0.1\",\n        \"tslib\": \"^2.4.1\",\n        \"turnstile-types\": \"^1.2.2\",\n        \"typescript\": \"^5.5.0\",\n        \"typescript-eslint\": \"^8.18.0\",\n        \"vite\": \"^5.4.4\"\n    }\n}\n"
  },
  {
    "path": "web/src/app.css",
    "content": ":root {\n    --primary: #ffffff;\n    --secondary: #000000;\n\n    --white: #ffffff;\n    --gray: #75757e;\n\n    --red: #ed2236;\n    --medium-red: #ce3030;\n    --dark-red: #d61c2e;\n    --green: #30bd1b;\n    --blue: #2f8af9;\n    --magenta: #eb445a;\n    --purple: #5857d4;\n    --orange: #f19a38;\n\n    --focus-ring: solid 2px var(--blue);\n    --focus-ring-offset: -2px;\n\n    --button: #f4f4f4;\n    --button-hover: #ededed;\n    --button-press: #e8e8e8;\n    --button-active-hover: #2a2a2a;\n\n    --button-hover-transparent: rgba(0, 0, 0, 0.06);\n    --button-press-transparent: rgba(0, 0, 0, 0.09);\n    --button-stroke: rgba(0, 0, 0, 0.06);\n    --button-text: #282828;\n    --button-box-shadow: 0 0 0 1px var(--button-stroke) inset;\n\n    --button-elevated: #e3e3e3;\n    --button-elevated-hover: #dadada;\n    --button-elevated-press: #d3d3d3;\n    --button-elevated-shimmer: #ededed;\n\n    --popover-glow: var(--button-stroke);\n\n    --popup-bg: #f1f1f1;\n    --popup-stroke: rgba(0, 0, 0, 0.08);\n\n    --dialog-backdrop: rgba(255, 255, 255, 0.3);\n\n    --sidebar-bg: var(--button);\n    --sidebar-highlight: var(--secondary);\n    --sidebar-stroke: rgba(0, 0, 0, 0.04);\n\n    --content-border: rgba(0, 0, 0, 0.03);\n    --content-border-thickness: 1px;\n\n    --input-border: #adadb7;\n\n    --toggle-bg: var(--input-border);\n    --toggle-bg-enabled: var(--secondary);\n\n    --padding: 12px;\n    --border-radius: 11px;\n\n    --sidebar-width: 80px;\n    --sidebar-font-size: 11px;\n    --sidebar-inner-padding: 4px;\n    --sidebar-tab-padding: 10px;\n\n    /* reduce default inset by 5px if it's not 0 */\n    --sidebar-height-mobile: calc(\n        50px +\n            calc(\n                env(safe-area-inset-bottom) - 5px *\n                    sign(env(safe-area-inset-bottom))\n            )\n    );\n\n    --safe-area-inset-top: env(safe-area-inset-top);\n    --safe-area-inset-bottom: env(safe-area-inset-bottom);\n\n    --switcher-padding: 3.5px;\n\n    /* used for fading the tab bar on scroll */\n    --sidebar-mobile-gradient: linear-gradient(\n        90deg,\n        rgba(0, 0, 0, 0.9) 0%,\n        rgba(0, 0, 0, 0) 5%,\n        rgba(0, 0, 0, 0) 50%,\n        rgba(0, 0, 0, 0) 95%,\n        rgba(0, 0, 0, 0.9) 100%\n    );\n\n    --skeleton-gradient: linear-gradient(\n        90deg,\n        var(--button-hover),\n        var(--button),\n        var(--button-hover)\n    );\n\n    --skeleton-gradient-elevated: linear-gradient(\n        90deg,\n        var(--button-elevated),\n        var(--button-elevated-shimmer),\n        var(--button-elevated)\n    );\n}\n\n[data-theme=\"dark\"] {\n    --primary: #000000;\n    --secondary: #e1e1e1;\n\n    --gray: #818181;\n\n    --blue: #2a7ce1;\n    --green: #37aa42;\n\n    --button: #191919;\n    --button-hover: #242424;\n    --button-press: #2a2a2a;\n\n    --button-active-hover: #f9f9f9;\n\n    --button-hover-transparent: rgba(225, 225, 225, 0.1);\n    --button-press-transparent: rgba(225, 225, 225, 0.15);\n    --button-stroke: rgba(255, 255, 255, 0.05);\n    --button-text: #e1e1e1;\n    --button-box-shadow: 0 0 0 1px var(--button-stroke) inset;\n\n    --button-elevated: #282828;\n    --button-elevated-hover: #2f2f2f;\n    --button-elevated-press: #343434;\n\n    --popover-glow: rgba(135, 135, 135, 0.12);\n\n    --popup-bg: #191919;\n    --popup-stroke: rgba(255, 255, 255, 0.08);\n\n    --dialog-backdrop: rgba(0, 0, 0, 0.3);\n\n    --sidebar-bg: #131313;\n    --sidebar-highlight: var(--secondary);\n    --sidebar-stroke: rgba(255, 255, 255, 0.04);\n\n    --content-border: rgba(255, 255, 255, 0.045);\n\n    --input-border: #383838;\n\n    --toggle-bg: var(--input-border);\n    --toggle-bg-enabled: #8a8a8a;\n\n    --sidebar-mobile-gradient: linear-gradient(\n        90deg,\n        rgba(19, 19, 19, 0.9) 0%,\n        rgba(19, 19, 19, 0) 5%,\n        rgba(19, 19, 19, 0) 50%,\n        rgba(19, 19, 19, 0) 95%,\n        rgba(19, 19, 19, 0.9) 100%\n    );\n\n    --skeleton-gradient: linear-gradient(\n        90deg,\n        var(--button),\n        var(--button-hover),\n        var(--button)\n    );\n\n    --skeleton-gradient-elevated: linear-gradient(\n        90deg,\n        var(--button-elevated),\n        var(--button-elevated-hover),\n        var(--button-elevated)\n    );\n}\n\n/* fall back to less pretty value cuz chrome doesn't support sign() */\n[data-chrome=\"true\"] {\n    --sidebar-height-mobile: calc(50px + env(safe-area-inset-bottom));\n}\n\n[data-theme=\"light\"] [data-reduce-transparency=\"true\"] {\n    --dialog-backdrop: rgba(255, 255, 255, 0.6);\n}\n\n[data-theme=\"dark\"] [data-reduce-transparency=\"true\"] {\n    --dialog-backdrop: rgba(0, 0, 0, 0.5);\n}\n\nhtml,\nbody {\n    margin: 0;\n    height: 100vh;\n    overflow: hidden;\n    overscroll-behavior-y: none;\n}\n\n* {\n    font-family: \"IBM Plex Mono\", monospace;\n    user-select: none;\n    scrollbar-width: none;\n    -webkit-user-select: none;\n    -webkit-user-drag: none;\n    -webkit-tap-highlight-color: transparent;\n}\n\n::-webkit-scrollbar {\n    display: none;\n}\n\n::selection {\n    color: var(--primary);\n    background: var(--secondary);\n}\n\na {\n    color: inherit;\n    text-underline-offset: 3px;\n    -webkit-touch-callout: none;\n}\n\na:visited {\n    color: inherit;\n}\n\nsvg,\nimg {\n    pointer-events: none;\n}\n\nbutton, .button {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    padding: 6px 13px;\n    gap: 6px;\n    border: none;\n    border-radius: var(--border-radius);\n    font-size: 14.5px;\n    cursor: pointer;\n    background-color: var(--button);\n    color: var(--button-text);\n    box-shadow: var(--button-box-shadow);\n}\n\n:focus-visible {\n    outline: none;\n}\n\nbutton:focus-visible,\na:focus-visible,\nselect:focus-visible {\n    outline: var(--focus-ring);\n    outline-offset: var(--focus-ring-offset);\n}\n\na:not(.sidebar-tab):not(.subnav-tab):focus-visible {\n    outline-offset: 3px;\n    border-radius: 2px;\n}\n\n.button.elevated {\n    background-color: var(--button-elevated);\n}\n\n.button.active {\n    color: var(--primary);\n    background-color: var(--secondary);\n}\n\n/* important is used because active class is toggled by state */\n/* and added to the end of the list, taking priority */\n.button.active:focus-visible,\na.active:focus-visible {\n    color: var(--white) !important;\n    background-color: var(--blue) !important;\n}\n\n@media (hover: hover) {\n    .button:hover {\n        background-color: var(--button-hover);\n    }\n\n    .button.elevated:not(.color):hover {\n        background-color: var(--button-elevated-hover);\n    }\n\n    .button.active:not(.color):hover {\n        background-color: var(--button-active-hover);\n    }\n}\n\n.button:active {\n    background-color: var(--button-press);\n}\n\n.button.elevated:not(.color):active {\n    background-color: var(--button-elevated-press);\n}\n\n.button.elevated {\n    box-shadow: none;\n}\n\n.button.active:not(.color):active {\n    background-color: var(--button-active-hover);\n}\n\nbutton[disabled] {\n    cursor: default;\n}\n\n/* workaround for typing into inputs being ignored on iPadOS 15 */\ninput {\n    user-select: text;\n    -webkit-user-select: text;\n}\n\n.center-column-container {\n    display: flex;\n    width: 100%;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n}\n\nbutton {\n    font-weight: 500;\n}\n\nh1, h2, h3, h4, h5, h6 {\n    font-weight: 500;\n    margin-block: 0;\n}\n\nh1 {\n    font-size: 24px;\n    letter-spacing: -1px;\n}\n\nh2 {\n    font-size: 20px;\n    letter-spacing: -1px;\n}\n\nh3 {\n    font-size: 16px;\n}\n\nh4 {\n    font-size: 14.5px;\n}\n\nh5 {\n    font-size: 12px;\n}\n\nh6 {\n    font-size: 11px;\n}\n\n.subtext {\n    font-size: 12.5px;\n    font-weight: 500;\n    color: var(--gray);\n    line-height: 1.4;\n    padding: 0 var(--padding);\n    white-space: pre-line;\n    user-select: text;\n    -webkit-user-select: text;\n}\n\n.long-text,\n.long-text *:not(h1, h2, h3, h4, h5, h6) {\n    line-height: 1.8;\n    font-size: 14.5px;\n    font-family: \"IBM Plex Mono\", monospace;\n    user-select: text;\n    -webkit-user-select: text;\n}\n\n.long-text,\n.long-text *:not(h1, h2, h3, h4, h5, h6, strong, em, del) {\n    font-weight: 400;\n}\n\n.long-text ul {\n    padding-inline-start: 30px;\n}\n\n.long-text li {\n    padding-left: 3px;\n}\n\n.long-text:not(.about) h1,\n.long-text:not(.about) h2,\n.long-text:not(.about) h3 {\n    user-select: text;\n    -webkit-user-select: text;\n    letter-spacing: 0;\n    margin-block-start: 1rem;\n}\n\n.long-text h3 {\n    font-size: 17px;\n}\n\n.long-text h2 {\n    font-size: 19px;\n}\n\n.long-text:not(.about) h3 {\n    margin-block-end: -0.5rem;\n}\n\n.long-text:not(.about) h2 {\n    font-size: 19px;\n    line-height: 1.3;\n    margin-block-end: -0.3rem;\n    padding: 6px 0;\n    border-bottom: 1.5px solid var(--button-elevated-hover);\n}\n\n.long-text img {\n    border-radius: 6px;\n}\n\ntable,\ntd,\nth {\n    border-spacing: 0;\n    border-style: solid;\n    border-width: 1px;\n    border-collapse: collapse;\n    text-align: center;\n    padding: 3px 8px;\n}\n\ncode {\n    background: var(--button-elevated);\n    padding: 1px 4px;\n    border-radius: 4px;\n}\n\ntr td:first-child,\ntr th:first-child {\n    text-align: right;\n}\n\n.long-text.about section p:first-of-type {\n    margin-block-start: 0.3em;\n}\n\n.long-text.about .heading-container {\n    padding-top: calc(var(--padding) / 2);\n}\n\n.long-text.about section:first-of-type .heading-container {\n    padding-top: 0;\n}\n\n\n@media screen and (max-width: 535px) {\n    .long-text,\n    .long-text *:not(h1, h2, h3, h4, h5, h6) {\n        font-size: 14px;\n    }\n}\n\n[data-reduce-motion=\"true\"] * {\n    animation: none !important;\n    transition: none !important;\n}\n\n@keyframes spinner {\n    0% {\n        transform: rotate(0deg);\n    }\n    100% {\n        transform: rotate(360deg);\n    }\n}\n"
  },
  {
    "path": "web/src/app.d.ts",
    "content": "// needed so that changelog files are appropriately\n// typed as svelte components\ndeclare module '*.md' {\n    import type { SvelteComponentDev } from 'svelte/internal';\n\n    export default class Comp extends SvelteComponentDev {\n        $$prop_def: {};\n    }\n    export const metadata: Record<string, any>;\n}\n\n// See https://kit.svelte.dev/docs/types#app\n// for information about these interfaces\ndeclare global {\n    namespace App {\n        // interface Error {}\n        // interface Locals {}\n        // interface PageData {}\n        // interface PageState {}\n        // interface Platform {}\n    }\n}\n\nexport {};\n"
  },
  {
    "path": "web/src/app.html",
    "content": "<!DOCTYPE html>\n<html>\n    <head>\n        <meta charset=\"utf-8\">\n        <meta name=\"viewport\" content=\"viewport-fit=cover, width=device-width, height=device-height, initial-scale=1, maximum-scale=1\">\n        <meta name=\"application-name\" content=\"cobalt\">\n        <meta name=\"og:type\" content=\"article\">\n        <meta name=\"twitter:card\" content=\"summary\">\n\n        %sveltekit.head%\n\n        <meta name=\"mobile-web-app-capable\" content=\"yes\">\n        <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\">\n        <meta name=\"apple-mobile-web-app-title\" content=\"cobalt\">\n\n        <meta name=\"darkreader-lock\">\n        <meta name=\"color-scheme\" content=\"only light\">\n\n        <link rel=\"icon\" href=\"%sveltekit.assets%/favicon.png\">\n        <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"%sveltekit.assets%/icons/apple-touch-icon.png\">\n        <link type=\"application/activity+json\" href=\"\">\n\n        <link crossorigin=\"use-credentials\" rel=\"manifest\" href=\"%sveltekit.assets%/manifest.json\">\n\n        <noscript>\n            <style>\n                #cobalt { opacity: 1 !important }\n            </style>\n        </noscript>\n\n        <style>\n            body, #body-dark {\n                background-color: black;\n            }\n\n            #body-light {\n                background-color: white;\n            }\n\n            @media (prefers-color-scheme: light) {\n                body {\n                    background-color: white;\n                }\n            }\n\n            #cobalt {\n                opacity: 0;\n            }\n\n            #cobalt.loaded {\n                opacity: 1;\n                transition: opacity .2s ease-out;\n            }\n        </style>\n        <script>\n            document.addEventListener('DOMContentLoaded', function() {\n                let settings = localStorage.getItem('settings'), appearance;\n                if (settings && ({ appearance } = JSON.parse(settings)) && appearance?.theme) {\n                    document.body.id = `body-${appearance.theme}`;\n                }\n            });\n        </script>\n    </head>\n    <body data-sveltekit-preload-data=\"hover\" data-sveltekit-preload-code=\"eager\">\n        <div style=\"display: contents\">\n            %sveltekit.body%\n        </div>\n    </body>\n</html>\n"
  },
  {
    "path": "web/src/components/about/AboutSupport.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n    import { openURL } from \"$lib/download\";\n\n    import IconExternalLink from \"@tabler/icons-svelte/IconExternalLink.svelte\";\n\n    import IconBrandGithub from \"@tabler/icons-svelte/IconBrandGithub.svelte\";\n    import IconBrandTwitter from \"@tabler/icons-svelte/IconBrandTwitter.svelte\";\n    import IconBrandDiscord from \"@tabler/icons-svelte/IconBrandDiscord.svelte\";\n    import IconBrandTelegram from \"@tabler/icons-svelte/IconBrandTelegram.svelte\";\n    import IconBrandBluesky from \"@tabler/icons-svelte/IconBrandBluesky.svelte\";\n\n    const platformIcons = {\n        github: {\n            icon: IconBrandGithub,\n            color: \"#8842cd\",\n        },\n        discord: {\n            icon: IconBrandDiscord,\n            color: \"#5865f2\",\n        },\n        twitter: {\n            icon: IconBrandTwitter,\n            color: \"#1da1f2\",\n        },\n        telegram: {\n            icon: IconBrandTelegram,\n            color: \"#1c9efb\",\n        },\n        bluesky: {\n            icon: IconBrandBluesky,\n            color: \"#0a78ff\",\n        },\n    };\n\n    export let platform: keyof typeof platformIcons;\n    export let externalLink: string;\n</script>\n\n<button\n    class=\"button support-card\"\n    role=\"link\"\n    on:click={() => {\n        openURL(externalLink);\n    }}\n>\n    <div class=\"support-card-header\">\n        <div\n            class=\"icon-holder support-icon-{platform}\"\n            style=\"\n                background-color: {platformIcons[platform].color};\n                box-shadow: 0 0 90px 10px {platformIcons[platform].color};\n            \"\n        >\n            <svelte:component this={platformIcons[platform].icon} />\n        </div>\n        <div class=\"support-card-title\">\n            {platform}\n            <IconExternalLink />\n        </div>\n    </div>\n    <div class=\"subtext support-card-description\">\n        {$t(`about.support.${platform}`)}\n    </div>\n</button>\n\n<style>\n    .support-card {\n        padding: var(--padding);\n        gap: 4px;\n\n        text-align: start;\n        flex-direction: column;\n        align-items: flex-start;\n        justify-content: flex-start;\n        overflow: hidden;\n    }\n\n    .support-card-header {\n        display: flex;\n        flex-direction: column;\n        align-items: flex-start;\n        gap: 8px;\n    }\n\n    .icon-holder {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        background-color: var(--gray);\n\n        padding: 4px;\n        border-radius: 5px;\n    }\n\n    .icon-holder :global(svg) {\n        width: 20px;\n        height: 20px;\n        stroke-width: 1.5px;\n        stroke: var(--white);\n    }\n\n    .support-card-title {\n        display: flex;\n        flex-direction: row;\n        align-items: center;\n        gap: 6px;\n    }\n\n    .support-card-title :global(svg) {\n        stroke: var(--secondary);\n        opacity: 0.5;\n        width: 14px;\n        height: 14px;\n    }\n\n    .support-card-description {\n        padding: 0;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/buttons/ActionButton.svelte",
    "content": "<script lang=\"ts\">\n    export let id: string;\n    export let click = () => {\n        alert(\"no function assigned\");\n    };\n</script>\n\n<button id=\"button-{id}\" class=\"button\" on:click={click}>\n    <slot></slot>\n</button>\n"
  },
  {
    "path": "web/src/components/buttons/SettingsButton.svelte",
    "content": "<script\n    lang=\"ts\"\n    generics=\"\n        Context extends Exclude<keyof CobaltSettings, 'schemaVersion'>,\n        Id extends keyof CobaltSettings[Context],\n        Value extends CobaltSettings[Context][Id]\n    \"\n>\n    import { hapticSwitch } from \"$lib/haptics\";\n\n    import settings, { updateSetting } from \"$lib/state/settings\";\n    import type { CobaltSettings } from \"$lib/types/settings\";\n\n    export let settingContext: Context;\n    export let settingId: Id;\n    export let settingValue: Value;\n\n    $: setting = $settings[settingContext][settingId];\n    $: isActive = setting === settingValue;\n</script>\n\n<button\n    id=\"setting-button-{settingContext}-{String(settingId)}-{settingValue}\"\n    class=\"button\"\n    class:active={isActive}\n    aria-pressed={isActive}\n    on:click={() => {\n        hapticSwitch();\n        updateSetting({\n            [settingContext]: {\n                [settingId]: settingValue,\n            },\n        });\n    }}\n>\n    <slot></slot>\n</button>\n"
  },
  {
    "path": "web/src/components/buttons/SettingsToggle.svelte",
    "content": "<script\n    lang=\"ts\"\n    generics=\"\n        Context extends Exclude<keyof CobaltSettings, 'schemaVersion'>,\n        Id extends keyof CobaltSettings[Context]\n    \"\n>\n    import { hapticSwitch } from \"$lib/haptics\";\n    import settings, { updateSetting } from \"$lib/state/settings\";\n    import type { CobaltSettings } from \"$lib/types/settings\";\n\n    import Toggle from \"$components/misc/Toggle.svelte\";\n\n    export let settingId: Id;\n    export let settingContext: Context;\n\n    export let title: string;\n    export let description: string = \"\";\n\n    export let disabled = false;\n    export let disabledOpacity = false;\n\n    $: setting = $settings[settingContext][settingId];\n    $: isEnabled = !!setting;\n</script>\n\n<div\n    id=\"setting-toggle-{settingContext}-{String(settingId)}\"\n    class=\"toggle-parent\"\n    class:disabled\n    class:faded={disabledOpacity}\n    aria-hidden={disabled}\n>\n    <button\n        class=\"button toggle-container\"\n        role=\"switch\"\n        aria-checked={isEnabled}\n        {disabled}\n        on:click={() => {\n            hapticSwitch();\n            updateSetting({\n                [settingContext]: {\n                    [settingId]: !isEnabled,\n                },\n            });\n        }}\n    >\n        <h4 class=\"toggle-title\">{title}</h4>\n        <Toggle enabled={isEnabled} />\n    </button>\n\n    {#if description}\n        <div class=\"subtext toggle-description\">{description}</div>\n    {/if}\n</div>\n\n<style>\n    .toggle-parent {\n        display: flex;\n        flex-direction: column;\n        gap: 8px;\n        overflow: hidden;\n        transition: opacity 0.2s;\n    }\n\n    .toggle-parent.disabled {\n        pointer-events: none;\n    }\n\n    .toggle-parent.faded {\n        opacity: 0.5;\n    }\n\n    .toggle-container {\n        width: 100%;\n        display: flex;\n        flex-direction: row;\n        align-items: center;\n        gap: var(--padding);\n        justify-content: space-between;\n        text-align: start;\n        transform: none;\n        padding: calc(var(--switcher-padding) * 2) 16px;\n        border-radius: var(--border-radius);\n        overflow: scroll;\n        transition: box-shadow 0.1s;\n    }\n\n    .toggle-container:active {\n        box-shadow:\n            var(--button-box-shadow),\n            0 0 0 1.5px var(--button-stroke) inset;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/buttons/Switcher.svelte",
    "content": "<script lang=\"ts\">\n    export let big: boolean = false;\n    export let description: string = \"\";\n</script>\n\n<div class=\"switcher-parent\">\n    <div class=\"switcher\" class:big={big} role=\"listbox\">\n        <slot></slot>\n    </div>\n    {#if description}\n        <div class=\"settings-content-description subtext\">{description}</div>\n    {/if}\n</div>\n\n<style>\n    .switcher-parent {\n        display: flex;\n        flex-direction: column;\n        gap: 8px;\n    }\n\n    .switcher {\n        display: flex;\n        width: auto;\n        flex-direction: row;\n        flex-wrap: nowrap;\n        scrollbar-width: none;\n        overflow-x: scroll;\n        border-radius: var(--border-radius);\n    }\n\n    .switcher :global(.button) {\n        white-space: nowrap;\n    }\n\n    .switcher:not(.big) :global(.button:first-child) {\n        border-top-right-radius: 0;\n        border-bottom-right-radius: 0;\n    }\n\n    .switcher:not(.big) :global(.button:last-child) {\n        border-top-left-radius: 0;\n        border-bottom-left-radius: 0;\n    }\n\n    .switcher:not(.big):dir(rtl) :global(.button:first-child) {\n        border-top-right-radius: inherit;\n        border-bottom-right-radius: inherit;\n        border-top-left-radius: 0;\n        border-bottom-left-radius: 0;\n    }\n\n    .switcher:not(.big):dir(rtl) :global(.button:last-child) {\n        border-top-left-radius: inherit;\n        border-bottom-left-radius: inherit;\n        border-top-right-radius: 0;\n        border-bottom-right-radius: 0;\n    }\n\n    .switcher.big {\n        background: var(--button);\n        box-shadow: var(--button-box-shadow);\n        padding: var(--switcher-padding);\n        gap: calc(var(--switcher-padding) - 1.5px);\n    }\n\n    .switcher :global(.button.active) {\n        pointer-events: none;\n    }\n\n    .switcher :global(.button.active:hover) {\n        background: var(--secondary);\n    }\n\n    .switcher.big :global(.button) {\n        width: 100%;\n        /* [base button height] - ([switcher padding] * [padding factor to accommodate for]) */\n        height: calc(40px - var(--switcher-padding) * 2);\n        border-radius: calc(var(--border-radius) - var(--switcher-padding));\n        box-shadow: none;\n    }\n\n    .switcher.big :global(.button:not(.active, :hover, :active)) {\n        background-color: transparent;\n    }\n\n    .switcher.big :global(.button:active:not(.active)) {\n        box-shadow: var(--button-box-shadow);\n    }\n\n    .switcher:not(.big) :global(.button:not(:first-child, :last-child)) {\n        border-radius: 0;\n    }\n\n    /* hack to get rid of double border in a list of switches */\n    .switcher:not(.big) :global(:not(.button:first-child)) {\n        margin-left: -1px;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/buttons/VerticalActionButton.svelte",
    "content": "<script lang=\"ts\">\n    export let id: string;\n    export let click = () => {\n        alert(\"no function assigned\");\n    };\n    export let fill = false;\n    export let elevated = false;\n    export let ariaLabel = \"\";\n</script>\n\n<button\n    id=\"button-{id}\"\n    class=\"button vertical\"\n    class:fill\n    class:elevated\n    on:click={click}\n    aria-label={ariaLabel}\n>\n    <slot></slot>\n</button>\n\n<style>\n    .button.vertical {\n        flex-direction: column;\n        line-height: 1;\n        padding: var(--padding);\n        gap: calc(var(--padding) / 2);\n    }\n\n    .button.vertical :global(svg) {\n        width: 24px;\n        height: 24px;\n    }\n\n    .button.vertical.fill {\n        width: 100%;\n        padding: var(--padding) 0;\n    }\n\n    .button.vertical :global(svg) {\n        stroke-width: 1.8px;\n        color: var(--secondary);\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/changelog/ChangelogEntry.svelte",
    "content": "<script lang=\"ts\">\n    import { onMount, type Snippet } from \"svelte\";\n    import type { Optional } from \"$lib/types/generic\";\n\n    import Skeleton from \"$components/misc/Skeleton.svelte\";\n\n    type Props = {\n        version: string;\n        title?: string;\n        date?: string;\n        banner?: Optional<{ file: string; alt: string }>;\n        skeleton?: boolean;\n        children?: Snippet\n    };\n\n    const { version, title, date, banner, skeleton, children }: Props = $props();\n\n    let bannerLoaded = $state(false);\n    let hideSkeleton = $state(false);\n\n    const formatDate = (dateString: string) => {\n        const date = new Date(dateString);\n        const months = [\"January\", \"February\", \"March\", \"April\", \"May\",\n                        \"June\", \"July\", \"August\", \"September\", \"October\",\n                        \"November\", \"December\"];\n\n        return [\n            months[date.getMonth()],\n            date.getDate() + \",\",\n            date.getFullYear(),\n        ].join(\" \");\n    };\n\n    const loaded = () => {\n        bannerLoaded = true;\n\n        // remove the skeleton after the image is done fading in\n        setTimeout(() => {\n            hideSkeleton = true;\n        }, 200)\n    }\n\n    onMount(() => {\n        const to_focus: HTMLElement | null =\n            document.querySelector(\"[data-first-focus]\");\n        to_focus?.focus();\n    });\n</script>\n\n<main id=\"changelog-parent\">\n    <div id=\"changelog-header\" class:no-padding={!banner && !skeleton}>\n        <div class=\"changelog-info\">\n            <div\n                class=\"changelog-version\"\n                data-first-focus\n                tabindex=\"-1\"\n            >\n                {version}\n            </div>\n            <div class=\"changelog-date\">\n                {#if skeleton}\n                    <Skeleton width=\"8em\" height=\"16px\" />\n                {:else if date}\n                    {formatDate(date)}\n                {/if}\n            </div>\n        </div>\n        {#if skeleton}\n            <Skeleton width=\"100%\" height=\"27.59px\" />\n        {:else}\n            <h1 class=\"changelog-title\">{title}</h1>\n        {/if}\n    </div>\n    <div class=\"changelog-content\">\n        {#if banner}\n            <div class=\"changelog-banner-container\">\n                <img\n                    src={`/update-banners/${banner.file}`}\n                    alt={banner.alt}\n                    class:loading={!bannerLoaded}\n                    onload={loaded}\n                    class=\"changelog-banner\"\n                />\n                <Skeleton class=\"big changelog-banner\" hidden={hideSkeleton} />\n            </div>\n        {/if}\n\n        {#if skeleton}\n            <Skeleton class=\"big changelog-banner\" width=\"100%\" />\n        {/if}\n        <div class=\"changelog-body long-text\">\n            {#if skeleton}\n                {#each { length: 3 + Math.random() * 5 } as _}\n                    <p>\n                        <Skeleton\n                            width=\"100%\"\n                            height={Math.random() * 84 + 16 + \"px\"}\n                        />\n                    </p>\n                {/each}\n            {:else}\n                {@render children?.()}\n            {/if}\n        </div>\n    </div>\n</main>\n\n<style>\n    #changelog-parent {\n        overflow-x: hidden;\n    }\n\n    #changelog-header {\n        display: flex;\n        flex-direction: column;\n        align-items: start;\n        gap: calc(var(--padding) / 2);\n        padding-bottom: 1em; /* match default <p> padding */\n    }\n\n    #changelog-header.no-padding {\n        padding-bottom: 0;\n    }\n\n    .changelog-info {\n        display: flex;\n        flex-direction: row;\n        align-items: center;\n        gap: 14px;\n    }\n\n    .changelog-version {\n        padding: 3px 8px;\n        border-radius: 6px;\n        background-color: var(--secondary);\n        color: var(--primary);\n        font-size: 18px;\n        font-weight: 500;\n    }\n\n    .changelog-date {\n        font-size: 13px;\n        font-weight: 500;\n        color: var(--gray);\n    }\n\n    .changelog-title {\n        padding: 0;\n        line-height: 1.2;\n        font-size: 23px;\n        user-select: text;\n        -webkit-user-select: text;\n    }\n\n    .changelog-banner-container {\n        object-fit: cover;\n        max-height: 350pt;\n        min-height: 180pt;\n        width: 100%;\n        aspect-ratio: 16/9;\n        position: relative;\n    }\n\n    :global(.changelog-banner) {\n        object-fit: cover;\n        width: 100%;\n        height: 100%;\n        aspect-ratio: 16/9;\n        border-radius: var(--padding);\n        pointer-events: all;\n\n        opacity: 1;\n        transition: opacity 0.15s;\n\n        position: absolute;\n        z-index: 2;\n    }\n\n    :global(.skeleton.changelog-banner) {\n        z-index: 1;\n        position: relative;\n    }\n\n    .changelog-banner.loading {\n        opacity: 0;\n    }\n\n    .changelog-content {\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n    }\n\n    .changelog-body {\n        width: 100%;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/changelog/ChangelogEntryWrapper.svelte",
    "content": "<!-- Workaround for https://github.com/pngwn/MDsveX/issues/116 -->\n<script lang=\"ts\" module>\n    import a from \"$components/misc/OuterLink.svelte\";\n    export { a };\n</script>\n\n<script lang=\"ts\">\n    import ChangelogEntry from \"$components/changelog/ChangelogEntry.svelte\";\n    import type { Snippet } from \"svelte\";\n\n    type Props = {\n        version: string;\n        title: string;\n        date: string;\n        banner?: any;\n        children: Snippet;\n    }\n\n    const { version, title, date, banner, children }: Props = $props();\n</script>\n\n<ChangelogEntry {version} {title} {date} {banner}>\n    {@render children?.()}\n</ChangelogEntry>\n"
  },
  {
    "path": "web/src/components/dialog/DialogBackdropClose.svelte",
    "content": "<script lang=\"ts\">\n    export let closeFunc: () => void;\n</script>\n\n<div\n    id=\"dialog-backdrop-close\"\n    aria-hidden=\"true\"\n    on:click={() => closeFunc()}\n></div>\n\n<style>\n    #dialog-backdrop-close {\n        position: inherit;\n        height: 100%;\n        width: 100%;\n        z-index: -1;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/dialog/DialogButton.svelte",
    "content": "<script lang=\"ts\">\n    import { onDestroy } from \"svelte\";\n    import type { DialogButton } from \"$lib/types/dialog\";\n\n    export let button: DialogButton;\n    export let closeFunc: () => void;\n\n    let disabled = false;\n    let seconds = 0;\n\n    if (button.timeout) {\n        disabled = true;\n        seconds = Math.round(button.timeout / 1000);\n\n        let interval = setInterval(() => {\n            seconds--;\n            if (seconds <= 0) {\n                clearInterval(interval);\n                disabled = false;\n            }\n        }, 1000);\n\n        onDestroy(() => clearInterval(interval));\n    }\n</script>\n{#if button.link}\n    <a\n        class=\"button elevated link-button\"\n        class:color={button.color}\n        class:active={button.main}\n        href={button.link}\n    >\n        {button.text}\n    </a>\n{:else}\n    <button\n        class=\"button elevated popup-button {button.color}\"\n        class:color={button.color}\n        class:active={button.main}\n        {disabled}\n        on:click={async () => {\n            await button.action();\n            closeFunc();\n        }}\n    >\n        {button.text}{seconds ? ` (${seconds})` : \"\"}\n    </button>\n{/if}\n<style>\n    .link-button {\n        text-decoration: none;\n        font-weight: 500;\n        width: 100%;\n    }\n\n    .popup-button {\n        width: 100%;\n        height: 40px;\n        transition: 0.2s opacity;\n    }\n\n    .popup-button.red {\n        background-color: var(--red);\n        color: var(--white);\n    }\n\n    .popup-button[disabled] {\n        opacity: 0.6;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/dialog/DialogButtons.svelte",
    "content": "<script lang=\"ts\">\n    import type { DialogButton as DialogButtonType } from \"$lib/types/dialog\";\n    import DialogButton from \"$components/dialog/DialogButton.svelte\";\n\n    export let buttons: DialogButtonType[];\n    export let closeFunc: () => void;\n</script>\n\n<div class=\"popup-buttons\">\n    {#each buttons as button}\n        <DialogButton {button} {closeFunc} />\n    {/each}\n</div>\n\n<style>\n    .popup-buttons {\n        display: flex;\n        flex-direction: row;\n        width: 100%;\n        gap: calc(var(--padding) / 2);\n        overflow: scroll;\n        border-radius: var(--border-radius);\n        min-height: 40px;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/dialog/DialogContainer.svelte",
    "content": "<script lang=\"ts\">\n    import { tick } from \"svelte\";\n    import { killDialog } from \"$lib/state/dialogs\";\n\n    import DialogBackdropClose from \"$components/dialog/DialogBackdropClose.svelte\";\n\n    export let id: string;\n    export let dismissable = true;\n\n    let dialogParent: HTMLDialogElement;\n\n    let open = false;\n    let closing = false;\n\n    export const close = () => {\n        if (dialogParent) {\n            closing = true;\n            open = false;\n\n            // wait 150ms for the closing animation to finish\n            setTimeout(() => {\n                // check if dialog parent is still present\n                if (dialogParent) {\n                    dialogParent.close();\n                    killDialog();\n                }\n            }, 150);\n        }\n    };\n\n    $: if (dialogParent) {\n        dialogParent.showModal();\n        tick().then(() => {\n            open = true;\n        });\n    }\n</script>\n\n<dialog id=\"dialog-{id}\" bind:this={dialogParent} class:closing class:open>\n    <slot></slot>\n    <DialogBackdropClose closeFunc={dismissable ? close : () => {}} />\n</dialog>\n"
  },
  {
    "path": "web/src/components/dialog/DialogHolder.svelte",
    "content": "<script lang=\"ts\">\n    import dialogs from \"$lib/state/dialogs\";\n\n    import SmallDialog from \"$components/dialog/SmallDialog.svelte\";\n    import PickerDialog from \"$components/dialog/PickerDialog.svelte\";\n    import SavingDialog from \"$components/dialog/SavingDialog.svelte\";\n    import NoScriptDialog from \"$components/dialog/NoScriptDialog.svelte\";\n\n    $: backdropVisible = $dialogs.length > 0;\n</script>\n\n<!--\n    this is the cleanest way of passing props without typescript throwing a fit.\n    more info here: https://github.com/microsoft/TypeScript/issues/46680\n-->\n<div id=\"dialog-holder\">\n    <NoScriptDialog />\n    {#each $dialogs as dialog}\n        {#if dialog.type === \"small\"}\n            <SmallDialog {...dialog} />\n        {:else if dialog.type === \"picker\"}\n            <PickerDialog {...dialog} />\n        {:else if dialog.type === \"saving\"}\n            <SavingDialog {...dialog} />\n        {/if}\n    {/each}\n    <div id=\"dialog-backdrop\" class:visible={backdropVisible}></div>\n</div>\n\n<style>\n    :global(dialog) {\n        display: flex;\n        flex-direction: column;\n        justify-content: center;\n        align-items: center;\n        background: none;\n\n        max-height: 100%;\n        max-width: 100%;\n        height: 100%;\n        width: 100%;\n        margin: 0;\n        padding: 0;\n        border: none;\n        pointer-events: all;\n\n        inset-inline-start: unset;\n        inset-inline-end: unset;\n\n        overflow: hidden;\n    }\n\n    :global(dialog:modal) {\n        inset-block-start: 0;\n        inset-block-end: 0;\n    }\n\n    :global(dialog:modal::backdrop) {\n        display: none;\n    }\n\n    #dialog-holder {\n        position: fixed;\n        padding-top: env(safe-area-inset-top);\n        height: 100%;\n        width: 100%;\n        z-index: 99;\n\n        display: flex;\n        justify-content: center;\n        align-items: center;\n\n        pointer-events: none;\n    }\n\n    #dialog-backdrop, :global(#nojs-dialog-backdrop) {\n        position: absolute;\n        height: 100%;\n        width: 100%;\n        z-index: -1;\n\n        background-color: var(--dialog-backdrop);\n\n        backdrop-filter: blur(7px);\n        -webkit-backdrop-filter: blur(7px);\n\n        opacity: 0;\n\n        will-change: opacity;\n        transition: opacity 0.2s;\n    }\n\n    #dialog-backdrop.visible {\n        opacity: 1;\n    }\n\n    :global([data-reduce-transparency=\"true\"]) #dialog-backdrop {\n        backdrop-filter: none !important;\n        -webkit-backdrop-filter: none !important;\n    }\n\n    :global(.dialog-body) {\n        --dialog-padding: 18px;\n\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n\n        background: var(--popup-bg);\n        box-shadow: 0 0 0 2px var(--popup-stroke) inset;\n        border-radius: 29px;\n\n        filter: drop-shadow(0 0 40px var(--button));\n\n        padding: var(--dialog-padding);\n\n        position: relative;\n        will-change: transform, opacity, filter;\n    }\n\n    :global(dialog.open .dialog-body) {\n        animation: modal-in 0.35s;\n        animation-delay: 0.06s;\n        animation-fill-mode: backwards;\n    }\n\n    :global(dialog.closing .dialog-body) {\n        animation: modal-out 0.15s;\n        opacity: 0;\n    }\n\n    @media screen and (max-width: 535px) {\n        :global(dialog) {\n            justify-content: flex-end;\n        }\n\n        :global(dialog.open .dialog-body) {\n            animation: modal-in-mobile 0.4s;\n        }\n\n        :global(dialog .dialog-body) {\n            margin-bottom: calc(\n                var(--padding) + calc(\n                    env(safe-area-inset-bottom) - 15px * sign(\n                        env(safe-area-inset-bottom)\n                    )\n                )\n            ) !important;\n            box-shadow: 0 0 0 2px var(--popup-stroke) inset;\n        }\n    }\n\n    @keyframes modal-in {\n        from {\n            transform: scale(0.8);\n            opacity: 0;\n        }\n        35% {\n            opacity: 1;\n        }\n        50% {\n            transform: scale(1.01);\n        }\n        100% {\n            transform: scale(1);\n        }\n    }\n\n    @keyframes modal-out {\n        from {\n            opacity: 1;\n        }\n        to {\n            opacity: 0;\n            transform: scale(0.9);\n            visibility: hidden;\n        }\n    }\n\n    @keyframes modal-in-mobile {\n        0% {\n            transform: translateY(0);\n            opacity: 0;\n        }\n        1% {\n            transform: translateY(200px);\n        }\n        35% {\n            opacity: 1;\n        }\n        50% {\n            transform: translateY(-5px);\n        }\n        100% {\n            transform: translateY(0px);\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/dialog/NoScriptDialog.svelte",
    "content": "<script lang=\"ts\">\n    import { browser } from \"$app/environment\";\n    import SmallDialog from \"$components/dialog/SmallDialog.svelte\";\n</script>\n\n{#if !browser}\n    <noscript style=\"display: contents\">\n        <div id=\"nojs-ack\">\n            <SmallDialog\n                id=\"nojs-dialog\"\n                meowbalt=\"error\"\n                bodyText={\n                    \"cobalt uses javascript for api requests and ui interactions, but it's not available in your browser. \"\n                    + \"you can still navigate around cobalt, but most functionality won't work.\"\n                }\n                buttons={[\n                    {\n                        text: \"got it\",\n                        main: true,\n                        action: () => {},\n                        link: \"#nojs-ack\"\n                    },\n                ]}\n            />\n            <div id=\"nojs-dialog-backdrop\"></div>\n        </div>\n    </noscript>\n{/if}\n\n<style>\n    :global(#nojs-ack) {\n        display: contents;\n    }\n\n    :global(#nojs-ack:target) {\n        display: none;\n    }\n\n    #nojs-dialog-backdrop {\n        opacity: 1;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/dialog/PickerDialog.svelte",
    "content": "<script lang=\"ts\">\n    import { device } from \"$lib/device\";\n    import { t } from \"$lib/i18n/translations\";\n\n    import type { Optional } from \"$lib/types/generic\";\n    import type { DialogButton } from \"$lib/types/dialog\";\n    import type { DialogPickerItem } from \"$lib/types/dialog\";\n\n    import DialogContainer from \"$components/dialog/DialogContainer.svelte\";\n\n    import PickerItem from \"$components/dialog/PickerItem.svelte\";\n    import DialogButtons from \"$components/dialog/DialogButtons.svelte\";\n\n    import IconBoxMultiple from \"@tabler/icons-svelte/IconBoxMultiple.svelte\";\n\n    export let id: string;\n    export let items: Optional<DialogPickerItem[]> = undefined;\n    export let buttons: Optional<DialogButton[]> = undefined;\n    export let dismissable = true;\n\n    let dialogDescription = \"dialog.picker.description.\";\n\n    if (device.is.iOS) {\n        dialogDescription += \"ios\";\n    } else if (device.is.mobile) {\n        dialogDescription += \"phone\";\n    } else {\n        dialogDescription += \"desktop\";\n    }\n\n    let close: () => void;\n</script>\n\n<DialogContainer {id} {dismissable} bind:close>\n    <div\n        class=\"dialog-body picker-dialog\"\n        class:three-columns={items && items.length <= 3}\n    >\n        <div class=\"popup-header\">\n            <div class=\"popup-title-container\">\n                <IconBoxMultiple />\n                <h2 class=\"popup-title\" tabindex=\"-1\">\n                    {$t(\"dialog.picker.title\")}\n                </h2>\n            </div>\n            <div class=\"subtext popup-description\">\n                {$t(dialogDescription)}\n            </div>\n        </div>\n        <div class=\"picker-body\">\n            {#if items}\n                {#each items as item, i}\n                    {#if item?.url}\n                        <PickerItem {item} number={i + 1} />\n                    {/if}\n                {/each}\n            {/if}\n        </div>\n        {#if buttons}\n            <DialogButtons {buttons} closeFunc={close} />\n        {/if}\n    </div>\n</DialogContainer>\n\n<style>\n    .picker-dialog {\n        --picker-item-size: 120px;\n        --picker-item-gap: 4px;\n        --picker-item-area: calc(var(--picker-item-size) + var(--picker-item-gap));\n\n        gap: var(--padding);\n        max-height: calc(\n            90% - env(safe-area-inset-bottom) - env(safe-area-inset-top)\n        );\n        width: auto;\n    }\n\n    .popup-header {\n        display: flex;\n        flex-direction: column;\n        align-items: flex-start;\n        gap: 3px;\n        max-width: calc(var(--picker-item-area) * 4);\n    }\n\n    .popup-title-container {\n        display: flex;\n        flex-direction: row;\n        align-items: center;\n        gap: calc(var(--padding) / 2);\n        color: var(--secondary);\n    }\n\n    .popup-title-container :global(svg) {\n        height: 21px;\n        width: 21px;\n    }\n\n    .popup-title {\n        font-size: 18px;\n        line-height: 1.1;\n    }\n\n    .popup-description {\n        font-size: 13px;\n        padding: 0;\n    }\n\n    .picker-body {\n        overflow-y: scroll;\n        display: grid;\n        justify-items: center;\n        grid-template-columns: 1fr 1fr 1fr 1fr;\n        gap: var(--picker-item-gap);\n    }\n\n    .three-columns .picker-body {\n        grid-template-columns: 1fr 1fr 1fr;\n    }\n\n    .three-columns .popup-header {\n        max-width: calc(var(--picker-item-area) * 3);\n    }\n\n    :global(.picker-item) {\n        width: var(--picker-item-size);\n        height: var(--picker-item-size);\n    }\n\n    @media screen and (max-width: 535px) {\n        .picker-body {\n            grid-template-columns: 1fr 1fr 1fr;\n        }\n\n        .popup-header {\n            max-width: calc(var(--picker-item-area) * 3);\n        }\n    }\n\n    @media screen and (max-width: 410px) {\n        .picker-dialog {\n            --picker-item-size: 118px;\n        }\n    }\n\n    @media screen and (max-width: 405px) {\n        .picker-dialog {\n            --picker-item-size: 116px;\n        }\n    }\n\n    @media screen and (max-width: 398px) {\n        .picker-dialog {\n            --picker-item-size: 115px;\n        }\n    }\n\n    @media screen and (max-width: 388px) {\n        .picker-dialog {\n            --picker-item-size: 110px;\n        }\n    }\n\n    @media screen and (max-width: 378px) {\n        .picker-dialog {\n            --picker-item-size: 105px;\n        }\n    }\n\n    @media screen and (max-width: 365px) {\n        .picker-dialog {\n            --picker-item-size: 100px;\n        }\n    }\n\n    @media screen and (max-width: 352px) {\n        .picker-dialog {\n            --picker-item-size: 95px;\n        }\n    }\n\n    @media screen and (max-width: 334px) {\n        .picker-dialog {\n            --picker-item-size: 130px;\n        }\n\n        .picker-body,\n        .three-columns .picker-body {\n            grid-template-columns: 1fr 1fr;\n        }\n    }\n\n    @media screen and (max-width: 300px) {\n        .picker-dialog {\n            --picker-item-size: 120px;\n        }\n    }\n\n    @media screen and (max-width: 280px) {\n        .picker-dialog {\n            --picker-item-size: 110px;\n        }\n    }\n\n    @media screen and (max-width: 255px) {\n        .picker-dialog {\n            --picker-item-size: 140px;\n        }\n\n        .picker-body,\n        .three-columns .picker-body {\n            grid-template-columns: 1fr;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/dialog/PickerItem.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n\n    import { downloadFile } from \"$lib/download\";\n    import type { DialogPickerItem } from \"$lib/types/dialog\";\n\n    import Skeleton from \"$components/misc/Skeleton.svelte\";\n\n    import IconMovie from \"@tabler/icons-svelte/IconMovie.svelte\";\n    import IconPhoto from \"@tabler/icons-svelte/IconPhoto.svelte\";\n    import IconGif from \"@tabler/icons-svelte/IconGif.svelte\";\n\n    type Props = {\n        item: DialogPickerItem;\n        number: number;\n    };\n\n    const { item, number }: Props = $props();\n\n    const itemType = $derived(item.type ?? \"photo\");\n\n    let imageLoaded = $state(false);\n    let hideSkeleton = $state(false);\n\n    let validUrl = false;\n    try {\n        new URL(item.url);\n        validUrl = true;\n    } catch {}\n\n    const isTunnel = validUrl && new URL(item.url).pathname === \"/tunnel\";\n\n    const loaded = () => {\n        imageLoaded = true;\n\n        // remove the skeleton after the image is done fading in\n        setTimeout(() => {\n            hideSkeleton = true;\n        }, 200)\n    }\n</script>\n\n<button\n    class=\"picker-item\"\n    onclick={() => {\n        if (validUrl) {\n            downloadFile({\n                url: item.url,\n                urlType: isTunnel ? \"tunnel\" : \"redirect\",\n            });\n        }\n    }}\n>\n    <div class=\"picker-type\">\n        {#if itemType === \"video\"}\n            <IconMovie />\n        {:else if itemType === \"gif\"}\n            <IconGif />\n        {:else}\n            <IconPhoto />\n        {/if}\n    </div>\n\n    <img\n        class=\"picker-image\"\n        src={item.thumb ?? item.url}\n        class:loading={!imageLoaded}\n        class:video-thumbnail={[\"video\", \"gif\"].includes(itemType)}\n        onload={loaded}\n        alt=\"{$t(`a11y.dialog.picker.item.${itemType}`)} {number}\"\n    />\n    <Skeleton class=\"picker-image elevated\" hidden={hideSkeleton} />\n</button>\n\n<style>\n    .picker-item {\n        position: relative;\n        background: none;\n        padding: 0;\n        box-shadow: none;\n        border-radius: calc(var(--border-radius) / 2 + 2px);\n    }\n\n    .picker-item:focus-visible::after {\n        content: \"\";\n        width: 100%;\n        height: 100%;\n        position: absolute;\n        outline: var(--focus-ring);\n        outline-offset: var(--focus-ring-offset);\n        border-radius: inherit;\n    }\n\n    :global(.picker-image) {\n        width: 100%;\n        height: 100%;\n\n        aspect-ratio: 1/1;\n        pointer-events: all;\n\n        object-fit: cover;\n        border-radius: inherit;\n\n        position: absolute;\n        z-index: 2;\n\n        opacity: 1;\n        transition: opacity 0.2s;\n    }\n\n    :global(.skeleton.picker-image) {\n        z-index: 1;\n        position: relative;\n    }\n\n    .picker-image.loading {\n        opacity: 0;\n    }\n\n    .picker-image.video-thumbnail {\n        pointer-events: none;\n    }\n\n    :global(.picker-item:active .picker-image) {\n        opacity: 0.75;\n    }\n\n    @media (hover: hover) {\n        :global(.picker-item:hover:not(:active) .picker-image) {\n            opacity: 0.8;\n        }\n    }\n\n    .picker-type {\n        position: absolute;\n        color: var(--white);\n        background: rgba(0, 0, 0, 0.5);\n        width: 24px;\n        height: 24px;\n        z-index: 3;\n\n        display: flex;\n        flex-direction: row;\n        align-items: center;\n        justify-content: center;\n\n        top: 6px;\n        left: 6px;\n\n        border-radius: 6px;\n\n        pointer-events: none;\n    }\n\n    .picker-type :global(svg) {\n        width: 22px;\n        height: 22px;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/dialog/SavingDialog.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n\n    import { device } from \"$lib/device\";\n    import { hapticConfirm } from \"$lib/haptics\";\n    import {\n        copyURL,\n        openURL,\n        shareURL,\n        openFile,\n        shareFile,\n    } from \"$lib/download\";\n\n    import type { CobaltFileUrlType } from \"$lib/types/api\";\n\n    import DialogContainer from \"$components/dialog/DialogContainer.svelte\";\n\n    import Meowbalt from \"$components/misc/Meowbalt.svelte\";\n    import DialogButtons from \"$components/dialog/DialogButtons.svelte\";\n    import SavingTutorial from \"$components/dialog/SavingTutorial.svelte\";\n    import VerticalActionButton from \"$components/buttons/VerticalActionButton.svelte\";\n\n    import IconShare2 from \"@tabler/icons-svelte/IconShare2.svelte\";\n    import IconDownload from \"@tabler/icons-svelte/IconDownload.svelte\";\n    import IconFileDownload from \"@tabler/icons-svelte/IconFileDownload.svelte\";\n\n    import CopyIcon from \"$components/misc/CopyIcon.svelte\";\n\n    export let id: string;\n    export let dismissable = true;\n    export let bodyText: string = \"\";\n\n    export let url: string = \"\";\n    export let file: File | undefined = undefined;\n    export let urlType: CobaltFileUrlType | undefined = undefined;\n\n    let close: () => void;\n\n    let copied = false;\n\n    $: if (copied) {\n        setTimeout(() => {\n            copied = false;\n        }, 1500);\n    }\n</script>\n\n<DialogContainer {id} {dismissable} bind:close>\n    <div class=\"dialog-body popup-body\">\n        <div class=\"meowbalt-container\">\n            <Meowbalt emotion=\"question\" />\n        </div>\n\n        <div class=\"dialog-inner-container\">\n            <div class=\"popup-header\">\n                <IconFileDownload />\n                <h2 class=\"popup-title\" tabindex=\"-1\">\n                    {$t(\"dialog.saving.title\")}\n                </h2>\n            </div>\n\n            <div class=\"action-buttons\">\n                {#if device.supports.directDownload && !(device.is.iOS && urlType === \"redirect\")}\n                    <VerticalActionButton\n                        id=\"save-download\"\n                        fill\n                        elevated\n                        click={() => {\n                            if (file) {\n                                return openFile(file);\n                            } else if (url) {\n                                return openURL(url);\n                            }\n                        }}\n                    >\n                        <IconDownload />\n                        {$t(\"button.download\")}\n                    </VerticalActionButton>\n                {/if}\n\n                {#if device.supports.share}\n                    <VerticalActionButton\n                        id=\"save-share\"\n                        fill\n                        elevated\n                        click={async () => {\n                            if (file) {\n                                return await shareFile(file);\n                            } else if (url) {\n                                return await shareURL(url);\n                            }\n                        }}\n                    >\n                        <IconShare2 />\n                        {$t(\"button.share\")}\n                    </VerticalActionButton>\n                {/if}\n\n                {#if !file}\n                    <VerticalActionButton\n                        id=\"save-copy\"\n                        fill\n                        elevated\n                        click={async () => {\n                            if (!copied) {\n                                copyURL(url);\n                                hapticConfirm();\n                                copied = true;\n                            }\n                        }}\n                        ariaLabel={copied ? $t(\"button.copied\") : \"\"}\n                    >\n                        <CopyIcon check={copied} />\n                        {$t(\"button.copy\")}\n                    </VerticalActionButton>\n                {/if}\n            </div>\n\n            {#if device.is.iOS}\n                <SavingTutorial />\n            {/if}\n\n            {#if bodyText}\n                <div class=\"body-text\">\n                    {bodyText}\n                </div>\n            {/if}\n        </div>\n\n        <DialogButtons\n            buttons={[\n                {\n                    text: $t(\"button.done\"),\n                    main: true,\n                    action: () => {},\n                },\n            ]}\n            closeFunc={close}\n        />\n    </div>\n</DialogContainer>\n\n<style>\n    .popup-body,\n    .dialog-inner-container {\n        display: flex;\n        flex-direction: column;\n        gap: var(--padding);\n    }\n\n    .dialog-inner-container {\n        overflow-y: scroll;\n        gap: 8px;\n        width: 100%;\n    }\n\n    .popup-body {\n        max-width: 340px;\n        width: calc(100% - var(--padding) - var(--dialog-padding) * 2);\n        max-height: 70%;\n        margin: calc(var(--padding) / 2);\n    }\n\n    .meowbalt-container {\n        position: absolute;\n        top: -126px;\n        right: 0;\n        /* simulate meowbalt being behind the popup */\n        clip-path: inset(0px 0px 14px 0px);\n    }\n\n    .popup-header {\n        display: flex;\n        flex-direction: row;\n        align-items: center;\n        gap: calc(var(--padding) / 2);\n        color: var(--secondary);\n    }\n\n    .popup-header :global(svg) {\n        height: 21px;\n        width: 21px;\n    }\n\n    .popup-title {\n        color: var(--secondary);\n        font-size: 19px;\n    }\n\n    .action-buttons {\n        display: flex;\n        flex-direction: row;\n        gap: calc(var(--padding) / 2);\n        position: relative;\n    }\n\n    .body-text {\n        font-size: 13px;\n        font-weight: 500;\n        line-height: 1.5;\n        color: var(--gray);\n        white-space: pre-wrap;\n        user-select: text;\n        -webkit-user-select: text;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/dialog/SavingTutorial.svelte",
    "content": "<script lang=\"ts\">\n    import { siriShortcuts } from \"$lib/env\";\n    import { t } from \"$lib/i18n/translations\";\n\n    import IconPlus from \"@tabler/icons-svelte/IconPlus.svelte\";\n    import IconFlower from \"@tabler/icons-svelte/IconFlower.svelte\";\n    import IconFolder from \"@tabler/icons-svelte/IconFolder.svelte\";\n\n    let tutorialExpanded = false;\n</script>\n\n<div id=\"saving-tutorial\" class:expanded={tutorialExpanded}>\n    <button\n        id=\"tutorial-button\"\n        class=\"button elevated\"\n        on:click={() => {\n            tutorialExpanded = !tutorialExpanded;\n        }}\n    >\n        <div class=\"expand-icon\">\n            <IconPlus />\n        </div>\n        {$t(\"save.tutorial.title\")}\n    </button>\n\n    {#if tutorialExpanded}\n        <div class=\"body-text tutorial-body\">\n            <div>\n                {$t(\"save.tutorial.intro\")}\n            </div>\n\n            <div class=\"numbered-list\">\n                <li>\n                    {$t(\"save.tutorial.step.1\")}\n                    <div class=\"shortcut-list\">\n                        <a\n                            class=\"button elevated shortcut\"\n                            href={siriShortcuts.photos}\n                            aria-label={$t(\n                                \"a11y.save.tutorial.shortcut.photos\"\n                            )}\n                        >\n                            <IconFlower />\n                            {$t(\"save.tutorial.shortcut.photos\")}\n                        </a>\n                        <a\n                            class=\"button elevated shortcut\"\n                            href={siriShortcuts.files}\n                            aria-label={$t(\n                                \"a11y.save.tutorial.shortcut.files\"\n                            )}\n                        >\n                            <IconFolder />\n                            {$t(\"save.tutorial.shortcut.files\")}\n                        </a>\n                    </div>\n                </li>\n                <li>\n                    {$t(\"save.tutorial.step.2\")}\n                </li>\n                <li>\n                    {$t(\"save.tutorial.step.3\")}\n                </li>\n            </div>\n\n            <div>\n                {$t(\"save.tutorial.outro\")}\n            </div>\n        </div>\n    {/if}\n</div>\n\n<style>\n    #saving-tutorial {\n        display: flex;\n        flex-direction: column;\n        background: var(--button-elevated);\n        border-radius: var(--border-radius);\n    }\n\n    #tutorial-button {\n        font-size: 13px;\n        width: 100%;\n        justify-content: flex-start;\n        padding: 8px;\n    }\n\n    .expand-icon {\n        height: 18px;\n        width: 18px;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n    }\n\n    .expand-icon :global(svg) {\n        height: 16px;\n        width: 16px;\n        stroke-width: 2px;\n        color: var(--secondary);\n        will-change: transform;\n    }\n\n    .expanded .expand-icon {\n        transform: rotate(45deg);\n    }\n\n    .tutorial-body *:not(.shortcut) {\n        font-size: 13px;\n        font-weight: 500;\n        line-height: 1.5;\n        white-space: pre-wrap;\n        user-select: text;\n        -webkit-user-select: text;\n    }\n\n    .tutorial-body {\n        color: var(--secondary);\n        padding: var(--padding);\n        padding-top: 6px;\n    }\n\n    .tutorial-body,\n    .numbered-list {\n        display: flex;\n        flex-direction: column;\n        gap: var(--padding);\n    }\n\n    .numbered-list {\n        list-style-type: decimal;\n    }\n\n    .numbered-list li {\n        margin-block: 0;\n    }\n\n    .shortcut-list {\n        display: flex;\n        padding-top: 6px;\n        gap: 6px;\n    }\n\n    .shortcut {\n        flex-direction: column;\n        background: var(--button-elevated-hover);\n        width: 100%;\n        gap: 3px;\n        text-decoration: none;\n        font-size: 13px;\n        padding: 8px;\n    }\n\n    .shortcut :global(svg) {\n        height: 21px;\n        width: 21px;\n        stroke-width: 1.5px;\n        stroke: var(--secondary);\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/dialog/SmallDialog.svelte",
    "content": "<script lang=\"ts\">\n    import { hapticError } from \"$lib/haptics\";\n    import type { Optional } from \"$lib/types/generic\";\n    import type { MeowbaltEmotions } from \"$lib/types/meowbalt\";\n    import type { DialogButton, SmallDialogIcons } from \"$lib/types/dialog\";\n\n    import DialogContainer from \"$components/dialog/DialogContainer.svelte\";\n\n    import Meowbalt from \"$components/misc/Meowbalt.svelte\";\n    import DialogButtons from \"$components/dialog/DialogButtons.svelte\";\n\n    import IconAlertTriangle from \"@tabler/icons-svelte/IconAlertTriangle.svelte\";\n\n    export let id: string;\n    export let meowbalt: Optional<MeowbaltEmotions> = undefined;\n    export let icon: Optional<SmallDialogIcons> = undefined;\n    export let title = \"\";\n    export let bodyText = \"\";\n    export let bodySubText = \"\";\n    export let buttons: Optional<DialogButton[]> = undefined;\n    export let dismissable = true;\n    export let leftAligned = false;\n\n    let close: () => void;\n\n    // error meowbalt art is not used in dialogs unless it's an error\n    if (meowbalt === \"error\") {\n        setTimeout(() => {\n            hapticError();\n        }, 150)\n    }\n</script>\n\n<DialogContainer {id} {dismissable} bind:close>\n    <div\n        class=\"dialog-body small-dialog\"\n        class:meowbalt-visible={meowbalt}\n        class:align-left={leftAligned}\n    >\n        {#if meowbalt}\n            <div class=\"meowbalt-container\">\n                <Meowbalt\n                    emotion={meowbalt}\n                    forceLoaded={id === 'nojs-dialog'}\n                />\n            </div>\n        {/if}\n        <div class=\"dialog-inner-container\">\n            {#if title || icon}\n                <div class=\"popup-header\">\n                    {#if icon === \"warn-red\"}\n                        <div class=\"popup-icon {icon}\">\n                            <IconAlertTriangle />\n                        </div>\n                    {/if}\n                    {#if title}\n                        <h2 class=\"popup-title\" tabindex=\"-1\">{title}</h2>\n                    {/if}\n                </div>\n            {/if}\n            {#if bodyText}\n                <div class=\"body-text\" tabindex=\"-1\">{bodyText}</div>\n            {/if}\n            {#if bodySubText}\n                <div class=\"subtext popup-subtext\">{bodySubText}</div>\n            {/if}\n        </div>\n        {#if buttons}\n            <DialogButtons {buttons} closeFunc={close} />\n        {/if}\n    </div>\n</DialogContainer>\n\n<style>\n    .small-dialog,\n    .dialog-inner-container {\n        display: flex;\n        flex-direction: column;\n        gap: var(--padding);\n    }\n\n    .dialog-inner-container {\n        overflow-y: scroll;\n        gap: 8px;\n    }\n\n    .small-dialog {\n        text-align: center;\n        max-width: 340px;\n        width: calc(100% - var(--padding) - var(--dialog-padding) * 2);\n        max-height: 85%;\n        margin: calc(var(--padding) / 2);\n    }\n\n    .small-dialog.meowbalt-visible {\n        padding-top: calc(var(--padding) * 4);\n    }\n\n    .meowbalt-container {\n        position: absolute;\n        top: -120px;\n    }\n\n    .popup-title {\n        color: var(--secondary);\n        font-size: 19px;\n    }\n\n    .popup-header,\n    .popup-icon {\n        display: flex;\n        flex-direction: column;\n        justify-content: center;\n        align-items: center;\n    }\n\n    .popup-icon :global(svg) {\n        stroke-width: 1.5px;\n        height: 50px;\n        width: 50px;\n    }\n\n    .warn-red :global(svg) {\n        stroke: var(--red);\n    }\n\n    .body-text {\n        font-size: 14.5px;\n        font-weight: 500;\n        line-height: 1.7;\n        color: var(--gray);\n        white-space: pre-wrap;\n        user-select: text;\n        -webkit-user-select: text;\n    }\n\n    .popup-subtext {\n        opacity: 0.7;\n        padding: 0;\n    }\n\n    .align-left .body-text {\n        text-align: start;\n    }\n\n    .align-left .popup-header {\n        align-items: start;\n        gap: 2px;\n    }\n\n    .align-left .popup-icon :global(svg) {\n        height: 40px;\n        width: 40px;\n        stroke-width: 1.8px;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/donate/DonateAltItem.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n    import { hapticConfirm } from \"$lib/haptics\";\n    import { copyURL, openURL } from \"$lib/download\";\n\n    import CopyIcon from \"$components/misc/CopyIcon.svelte\";\n    import IconExternalLink from \"@tabler/icons-svelte/IconExternalLink.svelte\";\n\n    export let type: \"copy\" | \"open\";\n    export let name: string;\n    export let address: string;\n    export let title = \"\";\n\n    let copied = false;\n\n    $: if (copied) {\n        setTimeout(() => {\n            copied = false;\n        }, 1500);\n    }\n</script>\n\n<div class=\"wallet-holder\">\n    <button\n        class=\"button wallet\"\n        aria-label={$t(`donate.alt.${type}`, {\n            value: name,\n        })}\n        on:click={() => {\n            if (type === \"copy\") {\n                if (!copied) {\n                    copyURL(address);\n                    hapticConfirm();\n                    copied = true;\n                }\n            } else {\n                openURL(address);\n            }\n        }}\n    >\n        <div class=\"wallet-icon\">\n            {#if type === \"copy\"}\n                <CopyIcon regularIcon={true} check={copied} />\n            {:else}\n                <IconExternalLink />\n            {/if}\n        </div>\n\n        <div class=\"wallet-text\">\n            <div class=\"wallet-name\">{name}</div>\n            <span class=\"wallet-address\">\n                {#if title}\n                    {title}\n                {:else if type === \"copy\"}\n                    {address}\n                {:else}\n                    {address.split(\"//\", 2)[1]}\n                {/if}\n            </span>\n        </div>\n    </button>\n</div>\n\n<style>\n    .wallet-holder {\n        display: flex;\n        gap: 6px;\n        flex-direction: column;\n        overflow: hidden;\n    }\n\n    .wallet-name {\n        font-size: 13px;\n        color: var(--gray);\n        font-weight: 400;\n    }\n\n    .wallet {\n        overflow: clip;\n        justify-content: flex-start;\n        align-items: center;\n        text-align: start;\n        line-break: anywhere;\n        padding: 0;\n        gap: 10px;\n    }\n\n    .wallet-icon {\n        min-width: 42px;\n        max-width: 42px;\n        height: 100%;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        border-right: 1px var(--button-stroke) solid;\n        margin-left: 3px;\n    }\n\n    .wallet-text {\n        display: flex;\n        flex-direction: column;\n        padding: 6px 0;\n    }\n\n    .wallet-icon :global(svg) {\n        width: 18px;\n        height: 18px;\n        color: var(--secondary);\n    }\n\n    .wallet-text,\n    .wallet-address {\n        overflow: hidden;\n        white-space: nowrap;\n        text-overflow: ellipsis;\n    }\n\n    .wallet-address {\n        font-size: 14px;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/donate/DonateBanner.svelte",
    "content": "<script lang=\"ts\">\n    import \"@fontsource/redaction-10/400.css\";\n\n    import { t } from \"$lib/i18n/translations\";\n\n    import Imput from \"$components/icons/Imput.svelte\";\n    import Meowbalt from \"$components/misc/Meowbalt.svelte\";\n</script>\n\n<header id=\"banner\">\n    <div id=\"banner-contents\">\n        <div id=\"banner-left\">\n            <div id=\"imput-logo\">\n                <Imput />\n            </div>\n            <div\n                id=\"banner-title\"\n                class=\"redaction\"\n                tabindex=\"-1\"\n                data-first-focus\n            >\n                {$t(\"donate.banner.title\")}\n            </div>\n            <div id=\"banner-subtitle\">{$t(\"donate.banner.subtitle\")}</div>\n        </div>\n        <div id=\"banner-right\">\n            <Meowbalt emotion=\"fast\" />\n        </div>\n    </div>\n    <div id=\"banner-background\">\n        <div id=\"banner-background-animation\">\n            <div id=\"banner-background-inner\">\n            </div>\n        </div>\n    </div>\n</header>\n\n<style>\n    #banner {\n        position: relative;\n        border-radius: var(--donate-border-radius);\n        background: linear-gradient(\n            180deg,\n            var(--donate-gradient-start) 30%,\n            var(--donate-gradient-end) 100%\n        );\n        box-shadow: 0 0 0 2px rgba(255, 255, 255, var(--donate-border-opacity))\n            inset;\n    }\n\n    #banner-contents {\n        position: relative;\n        display: flex;\n        width: 100%;\n    }\n\n    #banner-background {\n        position: absolute;\n        pointer-events: none;\n        top: 0;\n        width: 100%;\n        height: 100%;\n        z-index: 1;\n        opacity: 8%;\n        border-radius: var(--donate-border-radius);\n        mask-image: linear-gradient(\n            150deg,\n            rgba(0, 0, 0, 0.7) 0%,\n            rgba(255, 255, 255, 0) 65%\n        );\n    }\n\n    #banner-background-inner {\n        color: white;\n        transform: rotate(-10deg) scale(1.5) translateY(-70px);\n        background-image: url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"42\" height=\"40\" viewBox=\"2 7 21 10\" fill=\"none\" stroke=\"white\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572\"></path></svg>');\n        background-repeat: repeat;\n        width: 800px;\n        height: 400px;\n    }\n\n    #banner-background-inner :global(.heart-icon) {\n        height: 48px;\n        width: 48px;\n        stroke-width: 1.5px;\n        margin: -6px -2.5px;\n    }\n\n    #banner-right :global(.meowbalt) {\n        height: 330px;\n    }\n\n    #banner-right {\n        transform: translate(12px, 44px);\n        display: flex;\n        align-items: center;\n        position: absolute;\n        right: 0;\n        bottom: 0;\n    }\n\n    #banner-right:dir(rtl) {\n        position: relative;\n    }\n\n    #imput-logo {\n        display: flex;\n    }\n\n    #imput-logo :global(svg) {\n        width: 48px;\n        height: 42px;\n    }\n\n    #banner-left {\n        display: flex;\n        flex-direction: column;\n        justify-content: center;\n        color: white;\n        padding: 47px;\n        padding-right: 0;\n        gap: 14px;\n        white-space: pre-wrap;\n        max-width: 55%;\n    }\n\n    #banner-left:dir(rtl) {\n        padding-right: 47px;\n        padding-left: 0px;\n    }\n\n    #banner-title {\n        font-family: serif;\n        font-size: 48px;\n        font-weight: 400;\n        line-height: 0.95;\n    }\n\n    #banner-title.redaction {\n        font-family: \"Redaction 10\", serif;\n        font-smooth: never;\n        -webkit-font-smoothing: none;\n    }\n\n    #banner-subtitle {\n        color: var(--white);\n        opacity: 0.4;\n        line-height: 1.5;\n        font-size: 16px;\n    }\n\n    #banner-background-animation {\n        animation: heart-move 6s infinite linear;\n    }\n\n    @keyframes heart-move {\n        from {\n            transform: translateX(0) translateY(0);\n        }\n\n        to {\n            transform: translateX(83px) translateY(107px);\n        }\n    }\n\n    @media screen and (max-width: 1000px) {\n        #banner-right {\n            transform: translate(-4px, 44px);\n        }\n    }\n\n    @media screen and (max-width: 990px) {\n        #banner-right :global(.meowbalt) {\n            height: 300px;\n        }\n    }\n\n    @media screen and (max-width: 960px) {\n        #banner-right :global(.meowbalt) {\n            height: 280px;\n        }\n\n        #banner-right {\n            transform: translate(-4px, 30px);\n        }\n    }\n\n    @media screen and (max-width: 930px) {\n        #banner-right :global(.meowbalt) {\n            height: 260px;\n        }\n\n        #banner-right {\n            transform: translate(-4px, 20px);\n        }\n    }\n\n    @media screen and (max-width: 900px) {\n        #banner-right :global(.meowbalt) {\n            height: 230px;\n        }\n\n        #banner-right {\n            transform: translate(-10px, 15px);\n        }\n    }\n\n    @media screen and (max-width: 865px) {\n        #banner-right {\n            display: none;\n        }\n\n        #banner-background {\n            mask-image: linear-gradient(\n                180deg,\n                rgba(0, 0, 0, 0.5) 0%,\n                rgba(255, 255, 255, 0) 90%\n            );\n        }\n\n        #banner-contents {\n            justify-content: center;\n        }\n\n        #banner-left,\n        #banner-left:dir(rtl) {\n            max-width: 100%;\n            padding: 45px 12px;\n            gap: 14px;\n            align-items: center;\n        }\n\n        #banner-title,\n        #banner-subtitle {\n            text-align: center;\n        }\n    }\n\n    @media screen and (max-width: 610px) {\n        #banner-title {\n            font-size: 40px;\n        }\n    }\n\n    @media screen and (max-width: 550px) {\n        #banner-left,\n        #banner-left:dir(rtl) {\n            padding: 32px 12px;\n            gap: 12px;\n        }\n\n        #banner-title {\n            font-size: 36px;\n        }\n\n        #banner-subtitle {\n            font-size: 14px;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/donate/DonateCardContainer.svelte",
    "content": "<script lang=\"ts\">\n    export let id: string;\n    export let classes: string = \"\";\n</script>\n\n<div {id} class=\"donate-card {classes}\">\n    <slot></slot>\n</div>\n\n<style>\n    :global(.donate-card) {\n        --donate-card-main-padding: 16px;\n        --donate-card-padding: 12px;\n\n        display: flex;\n        flex-direction: column;\n        width: 100%;\n        height: fit-content;\n\n        border-radius: var(--donate-border-radius);\n        gap: calc(var(--donate-card-main-padding) / 2);\n\n        color: white;\n        background: linear-gradient(\n            180deg,\n            var(--donate-gradient-end) 0%,\n            var(--donate-gradient-start) 80%\n        );\n        box-shadow: 0 0 0 2px rgba(255, 255, 255, var(--donate-border-opacity))\n            inset;\n    }\n\n    :global(.donate-card button) {\n        display: flex;\n        flex-direction: column;\n        align-items: flex-start;\n        text-align: start;\n        border-radius: var(--donate-card-padding);\n        background: rgba(255, 255, 255, 0.05);\n        padding: 12px 16px;\n        color: var(--white);\n        gap: 0;\n        letter-spacing: -0.3px;\n    }\n\n    :global(.donate-card button) {\n        box-shadow: none;\n    }\n\n    @media (hover: hover) {\n        :global(.donate-card button:hover:not(.selected):not(.scroll-button)) {\n            background: rgba(255, 255, 255, 0.1);\n        }\n    }\n\n    :global(.donate-card button:active:not(.selected):not(.scroll-button)) {\n        background: rgba(255, 255, 255, 0.125);\n    }\n\n    :global(.donate-card button.selected) {\n        background: rgba(255, 255, 255, 0.15);\n        cursor: default;\n    }\n\n    :global(.donate-card button.selected) {\n        box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1) inset;\n    }\n\n    :global(.donate-card-subtitle) {\n        font-size: 13px;\n        color: var(--white);\n        opacity: 0.5;\n        line-height: 1.5;\n    }\n\n    :global(.donate-card-title) {\n        display: flex;\n        align-items: center;\n        font-size: 16px;\n        gap: 4px;\n        font-weight: 500;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/donate/DonateOptionsCard.svelte",
    "content": "<script lang=\"ts\">\n    import settings from \"$lib/state/settings\";\n\n    import { donate } from \"$lib/env\";\n    import { device } from \"$lib/device\";\n    import { t } from \"$lib/i18n/translations\";\n\n    import DonateCardContainer from \"$components/donate/DonateCardContainer.svelte\";\n    import DonationOption from \"$components/donate/DonationOption.svelte\";\n\n    import IconCoin from \"@tabler/icons-svelte/IconCoin.svelte\";\n    import IconCalendarRepeat from \"@tabler/icons-svelte/IconCalendarRepeat.svelte\";\n    import IconCup from \"@tabler/icons-svelte/IconCup.svelte\";\n    import IconPizza from \"@tabler/icons-svelte/IconPizza.svelte\";\n    import IconToolsKitchen2 from \"@tabler/icons-svelte/IconToolsKitchen2.svelte\";\n    import IconPaperBag from \"@tabler/icons-svelte/IconPaperBag.svelte\";\n    import IconSoup from \"@tabler/icons-svelte/IconSoup.svelte\";\n    import IconFridge from \"@tabler/icons-svelte/IconFridge.svelte\";\n    import IconArmchair from \"@tabler/icons-svelte/IconArmchair.svelte\";\n    import IconDeviceLaptop from \"@tabler/icons-svelte/IconDeviceLaptop.svelte\";\n    import IconApple from \"@tabler/icons-svelte/IconApple.svelte\";\n    import IconPhoto from \"@tabler/icons-svelte/IconPhoto.svelte\";\n    import IconWorldWww from \"@tabler/icons-svelte/IconWorldWww.svelte\";\n    import IconBath from \"@tabler/icons-svelte/IconBath.svelte\";\n\n    import IconArrowLeft from \"@tabler/icons-svelte/IconArrowLeft.svelte\";\n    import IconArrowRight from \"@tabler/icons-svelte/IconArrowRight.svelte\";\n\n    let donateList: HTMLElement;\n\n    let customInput: HTMLInputElement;\n    let customInputValue: number | null;\n    let customFocused = false;\n\n    const PRESET_DONATION_AMOUNTS = {\n        5: IconCup,\n        10: IconPizza,\n        15: IconSoup,\n        30: IconToolsKitchen2,\n        50: IconPaperBag,\n        100: IconWorldWww,\n        200: IconFridge,\n        500: IconArmchair,\n        1599: IconDeviceLaptop,\n        4900: IconApple,\n        7398: IconDeviceLaptop,\n        8629: IconPhoto,\n        9433: IconBath,\n    };\n\n    type Processor = \"stripe\" | \"liberapay\";\n    let processor: Processor = \"stripe\";\n\n    const donationMethods: Record<Processor, (_: number) => string> = {\n        stripe: (amount: number) => {\n            const url = new URL(donate.stripe);\n            url.searchParams.set(\"__prefilled_amount\", amount.toString());\n            return url.toString();\n        },\n        liberapay: (amount: number) => {\n            const url = new URL(donate.liberapay);\n            url.searchParams.set(\"currency\", \"USD\");\n            url.searchParams.set(\"period\", \"monthly\");\n            url.searchParams.set(\"amount\", (amount / 100).toString());\n            return url.toString();\n        },\n    };\n\n    const sendCustom = () => {\n        if (!customInput.reportValidity()) {\n            return;\n        }\n\n        const amount = Math.floor(Number(customInputValue) * 100);\n        return window.open(donationMethods[processor](amount), \"_blank\");\n    };\n\n    const scrollBehavior = $settings.accessibility.reduceMotion\n        ? \"instant\"\n        : \"smooth\";\n\n    $: showLeftScroll = false;\n    $: showRightScroll = true;\n\n    const scroll = (direction: \"left\" | \"right\") => {\n        const currentPos = donateList.scrollLeft;\n        const maxPos = donateList.scrollWidth - donateList.getBoundingClientRect().width;\n        const newPos = direction === \"left\" ? currentPos - 250 : currentPos + 250;\n\n        donateList.scroll({\n            left: newPos,\n            behavior: scrollBehavior,\n        });\n\n        setTimeout(() => {\n            showLeftScroll = newPos > 0;\n            showRightScroll = newPos < maxPos && newPos !== maxPos;\n        }, 150)\n    };\n</script>\n\n<DonateCardContainer id=\"donation-box\">\n    <div id=\"donation-types\" role=\"tablist\" aria-orientation=\"horizontal\">\n        <button\n            id=\"donation-type-once\"\n            class=\"donation-type\"\n            on:click={() => (processor = \"stripe\")}\n            class:selected={processor === \"stripe\"}\n            aria-selected={processor === \"stripe\"}\n            role=\"tab\"\n        >\n            <div class=\"donation-type-icon\"><IconCoin /></div>\n            <div class=\"donation-type-text\">\n                <div class=\"donate-card-title\">{$t(\"donate.card.once\")}</div>\n                <div class=\"donate-card-subtitle\">\n                    {$t(\"donate.card.processor\", { value: \"stripe\" })}\n                </div>\n            </div>\n        </button>\n\n        <button\n            id=\"donation-type-recurring\"\n            class=\"donation-type\"\n            on:click={() => (processor = \"liberapay\")}\n            class:selected={processor === \"liberapay\"}\n            aria-selected={processor === \"liberapay\"}\n            role=\"tab\"\n        >\n            <div class=\"donation-type-icon\"><IconCalendarRepeat /></div>\n            <div class=\"donation-type-text\">\n                <div class=\"donate-card-title\">{$t(\"donate.card.recurring\")}</div>\n                <div class=\"donate-card-subtitle\">\n                    {$t(\"donate.card.processor\", { value: \"liberapay\" })}\n                </div>\n            </div>\n        </button>\n    </div>\n\n    <div id=\"donation-options-container\">\n        {#if !device.is.mobile}\n            <div id=\"donation-options-scroll\" aria-hidden=\"true\">\n                <button\n                    class=\"scroll-button left\"\n                    class:hidden={!showLeftScroll}\n                    on:click={() => {\n                        scroll(\"left\");\n                    }}\n                >\n                    <IconArrowLeft />\n                </button>\n                <button\n                    class=\"scroll-button right\"\n                    class:hidden={!showRightScroll}\n                    on:click={() => {\n                        scroll(\"right\");\n                    }}\n                >\n                    <IconArrowRight />\n                </button>\n            </div>\n        {/if}\n\n        <div\n            id=\"donation-options\"\n            bind:this={donateList}\n            class:mask-both={!device.is.mobile && showLeftScroll && showRightScroll}\n            class:mask-left={!device.is.mobile && showLeftScroll && !showRightScroll}\n            class:mask-right={!device.is.mobile && showRightScroll && !showLeftScroll}\n            on:wheel={() => {\n                const currentPos = donateList.scrollLeft;\n                const maxPos = donateList.scrollWidth - donateList.getBoundingClientRect().width - 5;\n                showLeftScroll = currentPos > 0;\n                showRightScroll = currentPos < maxPos && currentPos !== maxPos;\n            }}\n        >\n            {#each Object.entries(PRESET_DONATION_AMOUNTS) as [amount, icon]}\n                <DonationOption\n                    price={+amount}\n                    desc={$t(`donate.card.option.${amount}`)}\n                    href={donationMethods[processor](+amount * 100)}\n                    {icon}\n                />\n            {/each}\n        </div>\n    </div>\n\n    <div id=\"donation-custom\">\n        <div id=\"input-container\" class:focused={customFocused}>\n            {#if customInputValue || customInput?.validity.badInput}\n                <span id=\"input-dollar-sign\">$</span>\n            {/if}\n\n            <input\n                id=\"donation-custom-input\"\n                type=\"number\"\n                min=\"2\"\n                max=\"10000\"\n                step=\".01\"\n                required\n                placeholder={$t(\"donate.card.custom\")}\n                bind:this={customInput}\n                bind:value={customInputValue}\n                on:input={() => (customFocused = true)}\n                on:focus={() => (customFocused = true)}\n                on:blur={() => (customFocused = false)}\n                on:keydown={(e) => e.key === \"Enter\" && sendCustom()}\n            />\n        </div>\n\n        <button\n            id=\"donation-custom-submit\"\n            on:click={sendCustom}\n            aria-label={$t(\"donate.card.custom.submit\")}\n            type=\"submit\"\n        >\n            <IconArrowRight />\n        </button>\n    </div>\n</DonateCardContainer>\n\n<style>\n    :global(#donation-box) {\n        min-width: 300px;\n        padding: var(--donate-card-main-padding) 0;\n    }\n\n    #donation-types,\n    #donation-options,\n    #donation-custom {\n        padding: 0 var(--donate-card-main-padding);\n    }\n\n    #donation-types {\n        display: flex;\n        flex-direction: row;\n        gap: calc(var(--donate-card-main-padding) / 2);\n        overflow: scroll;\n    }\n\n    .donation-type {\n        width: 100%;\n        overflow: hidden;\n        gap: 2px;\n    }\n\n    .donation-type-icon {\n        display: flex;\n    }\n\n    .donation-type-icon :global(svg) {\n        width: 28px;\n        height: 28px;\n        stroke-width: 1.8px;\n    }\n\n    .donation-type-text {\n        display: flex;\n        flex-direction: column;\n    }\n\n    #donation-options {\n        display: flex;\n        overflow-x: scroll;\n        gap: 6px;\n        mask-image: linear-gradient(\n            90deg,\n            rgba(0, 0, 0, 0) 0%,\n            rgba(0, 0, 0, 1) 3%,\n            rgba(0, 0, 0, 1) 50%,\n            rgba(0, 0, 0, 1) 97%,\n            rgba(0, 0, 0, 0) 100%\n        );\n    }\n\n    #donation-custom {\n        display: flex;\n        gap: 6px;\n        overflow: scroll;\n    }\n\n    #input-container {\n        padding: 0 18px;\n        width: 100%;\n        border-radius: 12px;\n        color: var(--white);\n        background: rgba(255, 255, 255, 0.05);\n        display: flex;\n        align-items: center;\n        gap: 4px;\n\n    }\n\n    @media (hover: hover) {\n        #input-container:hover {\n            background: rgba(255, 255, 255, 0.1);\n        }\n    }\n\n    #input-dollar-sign {\n        animation: grow-in 0.05s linear;\n        display: block;\n    }\n\n    @keyframes grow-in {\n        from {\n            font-size: 0;\n        }\n        to {\n            font-size: inherit;\n        }\n    }\n\n    #input-container,\n    #donation-custom-input {\n        font-size: 13px;\n    }\n\n    #donation-custom-input {\n        flex: 1;\n        background-color: transparent;\n        color: var(--white);\n        border: none;\n        padding-block: 0;\n        padding-inline: 0;\n        padding: 12px 0;\n        appearance: textfield;\n    }\n\n    #donation-custom-input::placeholder {\n        color: var(--white);\n        opacity: 0.5;\n    }\n\n    #input-container.focused {\n        box-shadow: 0 0 0 2px var(--white) inset;\n    }\n\n    #donation-custom-submit {\n        color: var(--white);\n        aspect-ratio: 1/1;\n        padding: 0px 10px;\n    }\n\n    #donation-custom-submit :global(svg) {\n        width: 24px;\n        height: 24px;\n    }\n\n    #donation-custom-input::-webkit-outer-spin-button,\n    #donation-custom-input::-webkit-inner-spin-button {\n        -webkit-appearance: none;\n        margin: 0;\n    }\n\n    #donation-options-container {\n        display: flex;\n        flex-direction: column;\n        gap: calc(var(--donate-card-main-padding) / 2);\n        position: relative;\n\n        &:hover {\n            & > #donation-options-scroll {\n                opacity: 1;\n            }\n\n            & > #donation-options {\n                &.mask-both {\n                    mask-image: linear-gradient(\n                        90deg,\n                        rgba(0, 0, 0, 0) 0%,\n                        rgba(0, 0, 0, 1) 20%,\n                        rgba(0, 0, 0, 1) 50%,\n                        rgba(0, 0, 0, 1) 80%,\n                        rgba(0, 0, 0, 0) 100%\n                    );\n                }\n\n                &.mask-left {\n                    mask-image: linear-gradient(\n                        90deg,\n                        rgba(0, 0, 0, 0) 0%,\n                        rgba(0, 0, 0, 1) 20%,\n                        rgba(0, 0, 0, 1) 50%,\n                        rgba(0, 0, 0, 1) 97%,\n                        rgba(0, 0, 0, 0) 100%\n                    );\n                }\n\n                &.mask-right {\n                    mask-image: linear-gradient(\n                        90deg,\n                        rgba(0, 0, 0, 0) 0%,\n                        rgba(0, 0, 0, 1) 3%,\n                        rgba(0, 0, 0, 1) 50%,\n                        rgba(0, 0, 0, 1) 80%,\n                        rgba(0, 0, 0, 0) 100%\n                    );\n                }\n            }\n        }\n\n        &:not(:hover) {\n            & > #donation-options-scroll .scroll-button {\n                visibility: hidden;\n            }\n        }\n    }\n\n    #donation-options-scroll {\n        display: flex;\n        flex-direction: row;\n        justify-content: space-between;\n        align-items: center;\n        position: absolute;\n        pointer-events: none;\n        width: 100%;\n        height: 100%;\n        z-index: 3;\n        overflow: hidden;\n        opacity: 0;\n        transition: opacity 0.2s;\n    }\n\n    .scroll-button {\n        pointer-events: all;\n        color: white;\n        padding: 0 16px;\n        background-color: transparent;\n        height: 100%;\n\n        &.hidden {\n            visibility: hidden;\n        }\n    }\n\n    @media screen and (max-width: 550px) {\n        :global(#donation-box) {\n            --donate-card-main-padding: 14px;\n            min-width: unset;\n        }\n\n        :global(#donation-box .donate-card-title) {\n            font-size: 14px;\n        }\n\n        :global(#donation-box .donate-card-subtitle) {\n            font-size: 12px;\n        }\n\n        .donation-type-icon :global(svg) {\n            width: 26px;\n            height: 26px;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/donate/DonateShareCard.svelte",
    "content": "<script lang=\"ts\">\n    import { contacts } from \"$lib/env\";\n    import { device } from \"$lib/device\";\n    import locale from \"$lib/i18n/locale\";\n    import { t } from \"$lib/i18n/translations\";\n    import { hapticConfirm } from \"$lib/haptics\";\n\n    import { openURL, copyURL, shareURL } from \"$lib/download\";\n\n    import DonateCardContainer from \"$components/donate/DonateCardContainer.svelte\";\n\n    import IconShare2 from \"@tabler/icons-svelte/IconShare2.svelte\";\n    import IconBrandGithub from \"@tabler/icons-svelte/IconBrandGithub.svelte\";\n    import IconBrandTwitter from \"@tabler/icons-svelte/IconBrandTwitter.svelte\";\n    import IconMoodSmileBeam from \"@tabler/icons-svelte/IconMoodSmileBeam.svelte\";\n\n    import CobaltQr from \"$components/icons/CobaltQR.svelte\";\n    import CopyIcon from \"$components/misc/CopyIcon.svelte\";\n\n    const cobaltUrl = \"https://cobalt.tools/\";\n\n    let copied = false;\n\n    $: if (copied) {\n        setTimeout(() => {\n            copied = false;\n        }, 1500);\n    }\n\n    let expanded = false;\n</script>\n\n<DonateCardContainer id=\"share-box\" classes={expanded ? \"expanded\" : \"\"}>\n    <div id=\"share-card-header\">\n        <div class=\"share-header-icon\"><IconMoodSmileBeam /></div>\n        <div class=\"donate-card-title\">{$t(\"donate.share.title\")}</div>\n    </div>\n    <div id=\"share-card-body\">\n        <button\n            id=\"share-qr\"\n            on:click={() => {\n                expanded = !expanded;\n            }}\n            aria-label={$t(\n                `a11y.donate.share.qr.${expanded ? \"collapse\" : \"expand\"}`\n            )}\n        >\n            <CobaltQr />\n        </button>\n        <div id=\"action-buttons\">\n            <button\n                id=\"action-button-copy\"\n                class=\"action-button\"\n                on:click={async () => {\n                    if (!copied) {\n                        copyURL(cobaltUrl);\n                        hapticConfirm();\n                        copied = true;\n                    }\n                }}\n                aria-label={copied ? $t(\"button.copied\") : \"\"}\n            >\n                <div class=\"action-button-icon\">\n                    <CopyIcon check={copied} />\n                </div>\n                {$t(\"button.copy\")}\n            </button>\n\n            {#if device.supports.share}\n                <button\n                    id=\"action-button-share\"\n                    class=\"action-button\"\n                    on:click={async () => shareURL(cobaltUrl)}\n                >\n                    <div class=\"action-button-icon\">\n                        <IconShare2 />\n                    </div>\n                    {$t(\"button.share\")}\n                </button>\n            {/if}\n\n            <button\n                id=\"action-button-github\"\n                class=\"action-button\"\n                on:click={async () => openURL(contacts.github)}\n            >\n                <div class=\"action-button-icon\">\n                    <IconBrandGithub />\n                </div>\n                {$t(\"button.star\")}\n            </button>\n\n            {#if $locale !== \"ru\"}\n                <button\n                    id=\"action-button-twitter\"\n                    class=\"action-button\"\n                    on:click={async () => openURL(contacts.twitter)}\n                >\n                    <div class=\"action-button-icon\">\n                        <IconBrandTwitter />\n                    </div>\n                    {$t(\"button.follow\")}\n                </button>\n            {/if}\n        </div>\n    </div>\n    <div\n        class=\"donate-card-subtitle share-footer-link\"\n        class:centered={expanded}\n    >\n        cobalt.tools\n    </div>\n</DonateCardContainer>\n\n<style>\n    :global(#share-box) {\n        padding: var(--donate-card-main-padding);\n        min-width: 320px;\n        width: fit-content;\n        transition: box-shadow 0.15s;\n    }\n\n    #share-card-header {\n        display: flex;\n        flex-direction: column;\n        gap: 2px;\n    }\n\n    .centered {\n        text-align: center;\n    }\n\n    .share-header-icon {\n        display: flex;\n    }\n\n    .share-header-icon :global(svg) {\n        width: 28px;\n        height: 28px;\n        stroke-width: 1.8px;\n    }\n\n    #share-card-body {\n        display: flex;\n        flex-direction: row;\n        gap: 12px;\n    }\n\n    #share-qr {\n        display: flex;\n        justify-content: flex-start;\n        align-items: baseline;\n        aspect-ratio: 1 / 1;\n        padding: 0;\n        background: none;\n    }\n\n    #share-qr :global(svg) {\n        width: 132px;\n        height: 132px;\n        border-radius: 12px;\n        box-shadow: 0 0 0 2px rgba(255, 255, 255, var(--donate-border-opacity));\n    }\n\n    #share-qr:focus-visible :global(svg) {\n        outline: var(--focus-ring);\n        outline-offset: var(--focus-ring-offset);\n    }\n\n    #action-buttons {\n        display: grid;\n        grid-template-columns: 1fr 1fr;\n        width: 100%;\n        gap: 6px;\n    }\n\n    .action-button {\n        align-items: center;\n        width: 100%;\n        padding: 0 6px;\n        font-size: 13px;\n        gap: 2px;\n    }\n\n    .action-button-icon {\n        width: 21px;\n        height: 21px;\n        display: flex;\n    }\n\n    .action-button-icon :global(svg) {\n        width: 21px;\n        height: 21px;\n        stroke-width: 1.8px;\n    }\n\n    :global(#share-box.expanded) {\n        margin-bottom: -300px;\n        z-index: 1;\n        box-shadow:\n            0 0 0 2px rgba(255, 255, 255, var(--donate-border-opacity)) inset,\n            0 0 20px 3px rgba(0, 0, 0, 0.5);\n    }\n\n    :global(#share-box.expanded #share-qr svg) {\n        width: 99%;\n        height: 99%;\n        transition: all 0.15s;\n    }\n\n    :global(#share-box.expanded) #share-card-body {\n        flex-direction: column;\n        max-height: unset;\n    }\n\n    :global(#share-box.expanded) #action-buttons {\n        display: flex;\n    }\n\n    :global(#share-box.expanded) .action-button {\n        padding: 10px;\n        transition: all 0.15s;\n    }\n\n    @media screen and (max-width: 760px) {\n        :global(#share-box) {\n            width: calc(100% - var(--donate-card-main-padding) * 2);\n            background: var(--donate-gradient-start);\n            min-width: unset;\n        }\n\n        :global(#share-box.expanded) {\n            margin-bottom: unset;\n            z-index: unset;\n            box-shadow: 0 0 0 2px\n                rgba(255, 255, 255, var(--donate-border-opacity)) inset;\n            transition: none;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/donate/DonationOption.svelte",
    "content": "<script lang=\"ts\">\n    export let price: number;\n    export let desc: string;\n    export let href: string;\n    export let icon: ConstructorOfATypedSvelteComponent;\n\n    const USD = new Intl.NumberFormat(\"en-US\", {\n        style: \"currency\",\n        currency: \"USD\",\n        minimumFractionDigits: 0,\n    });\n</script>\n\n<button\n    class=\"donation-option\"\n    on:click={() => {\n        window.open(href, \"_blank\");\n    }}\n>\n    <div class=\"donate-card-title\">\n        <svelte:component this={icon} />\n        {USD.format(price)}\n    </div>\n    <div class=\"donate-card-subtitle\">{desc}</div>\n</button>\n\n<style>\n    .donation-option .donate-card-subtitle {\n        white-space: nowrap;\n    }\n\n    .donation-option :global(svg) {\n        width: 20px;\n        height: 20px;\n        stroke-width: 1.8px;\n    }\n\n    @media screen and (max-width: 550px) {\n        .donation-option :global(svg) {\n            width: 18px;\n            height: 18px;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/icons/Clipboard.svelte",
    "content": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 32 32\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n    <path d=\"M25.5 2H6.5C5.67157 2 5 2.67157 5 3.5V27.5C5 28.3284 5.67157 29 6.5 29H16.4244C16.795 29 17.1524 28.8628 17.4278 28.6149L26.5034 20.4469C26.8195 20.1624 27 19.7572 27 19.332V3.5C27 2.67157 26.3284 2 25.5 2Z\" fill=\"#DA882F\"/>\n    <rect x=\"7\" y=\"4\" width=\"18\" height=\"23\" rx=\"1\" fill=\"#F5F5F5\"/>\n    <path d=\"M18 3C18 1.89543 17.1046 1 16 1C14.8954 1 14 1.89543 14 3H13C11.8954 3 11 3.89543 11 5V6.5C11 6.77614 11.2239 7 11.5 7H20.5C20.7761 7 21 6.77614 21 6.5V5C21 3.89543 20.1046 3 19 3H18ZM17 3C17 3.55228 16.5523 4 16 4C15.4477 4 15 3.55228 15 3C15 2.44772 15.4477 2 16 2C16.5523 2 17 2.44772 17 3Z\" fill=\"#8F8F8F\"/>\n    <path d=\"M28 11C28.5523 11 29 11.4477 29 12V26L28.5 26.5L24 31H14C13.4477 31 13 30.5523 13 30V12C13 11.4477 13.4477 11 14 11H28Z\" fill=\"#D9D9D9\"/>\n    <path d=\"M29 26H24.846C24.3788 26 24 26.3788 24 26.846V31C24.0914 30.9584 24.1755 30.9005 24.2478 30.8282L28.8282 26.2478C28.9005 26.1755 28.9584 26.0914 29 26Z\" fill=\"#BDBDBD\"/>\n    <path d=\"M15 15.5C15 15.2239 15.1919 15 15.4286 15H26.5714C26.8081 15 27 15.2239 27 15.5C27 15.7761 26.8081 16 26.5714 16H15.4286C15.1919 16 15 15.7761 15 15.5Z\" fill=\"#8F8F8F\"/>\n    <path d=\"M15 18.5C15 18.2239 15.1919 18 15.4286 18H26.5714C26.8081 18 27 18.2239 27 18.5C27 18.7761 26.8081 19 26.5714 19H15.4286C15.1919 19 15 18.7761 15 18.5Z\" fill=\"#8F8F8F\"/>\n    <path d=\"M15.4286 21C15.1919 21 15 21.2239 15 21.5C15 21.7761 15.1919 22 15.4286 22H26.5714C26.8081 22 27 21.7761 27 21.5C27 21.2239 26.8081 21 26.5714 21H15.4286Z\" fill=\"#8F8F8F\"/>\n    <path d=\"M15 24.5C15 24.2239 15.199 24 15.4444 24H22.5556C22.801 24 23 24.2239 23 24.5C23 24.7761 22.801 25 22.5556 25H15.4444C15.199 25 15 24.7761 15 24.5Z\" fill=\"#8F8F8F\"/>\n</svg>\n"
  },
  {
    "path": "web/src/components/icons/Cobalt.svelte",
    "content": "<svg width=\"24\" height=\"16\" viewBox=\"0 0 24 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n    <path d=\"M0 15.6363L0 12.8594L9.47552 8.293L0 3.14038L0 0.363525L12.8575 7.4908V9.21862L0 15.6363Z\" fill=\"white\"/>\n    <path d=\"M11.1425 15.6363V12.8594L20.6181 8.293L11.1425 3.14038V0.363525L24 7.4908V9.21862L11.1425 15.6363Z\" fill=\"white\"/>\n</svg>\n"
  },
  {
    "path": "web/src/components/icons/CobaltQR.svelte",
    "content": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"378\" height=\"378\" viewBox=\"0 0 29 29\">\n  <rect width=\"100%\" height=\"100%\" style=\"fill:#fff\"/>\n  <path d=\"M4 4h1v1H4zm1 0h1v1H5zm1 0h1v1H6zm1 0h1v1H7zm1 0h1v1H8zm1 0h1v1H9zm1 0h1v1h-1zm3 0h1v1h-1zm1 0h1v1h-1zm4 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zM4 5h1v1H4zm6 0h1v1h-1zm2 0h1v1h-1zm1 0h1v1h-1zm5 0h1v1h-1zm6 0h1v1h-1zM4 6h1v1H4zm2 0h1v1H6zm1 0h1v1H7zm1 0h1v1H8zm2 0h1v1h-1zm3 0h1v1h-1zm2 0h1v1h-1zm1 0h1v1h-1zm2 0h1v1h-1zm2 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm2 0h1v1h-1zM4 7h1v1H4zm2 0h1v1H6zm1 0h1v1H7zm1 0h1v1H8zm2 0h1v1h-1zm5 0h1v1h-1zm3 0h1v1h-1zm2 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm2 0h1v1h-1zM4 8h1v1H4zm2 0h1v1H6zm1 0h1v1H7zm1 0h1v1H8zm2 0h1v1h-1zm2 0h1v1h-1zm2 0h1v1h-1zm2 0h1v1h-1zm2 0h1v1h-1zm2 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm2 0h1v1h-1zM4 9h1v1H4zm6 0h1v1h-1zm5 0h1v1h-1zm3 0h1v1h-1zm6 0h1v1h-1zM4 10h1v1H4zm1 0h1v1H5zm1 0h1v1H6zm1 0h1v1H7zm1 0h1v1H8zm1 0h1v1H9zm1 0h1v1h-1zm2 0h1v1h-1zm2 0h1v1h-1zm2 0h1v1h-1zm2 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm-9 1h1v1h-1zm1 0h1v1h-1zM4 12h1v1H4zm2 0h1v1H6zm2 0h1v1H8zm2 0h1v1h-1zm3 0h1v1h-1zm7 0h1v1h-1zm3 0h1v1h-1zM6 13h1v1H6zm5 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm5 0h1v1h-1zm2 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zM4 14h1v1H4zm1 0h1v1H5zm2 0h1v1H7zm1 0h1v1H8zm2 0h1v1h-1zm2 0h1v1h-1zm2 0h1v1h-1zm3 0h1v1h-1zm3 0h1v1h-1zM4 15h1v1H4zm1 0h1v1H5zm1 0h1v1H6zm5 0h1v1h-1zm1 0h1v1h-1zm2 0h1v1h-1zm2 0h1v1h-1zm1 0h1v1h-1zm2 0h1v1h-1zM4 16h1v1H4zm2 0h1v1H6zm1 0h1v1H7zm1 0h1v1H8zm1 0h1v1H9zm1 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm3 0h1v1h-1zm1 0h1v1h-1zm3 0h1v1h-1zm1 0h1v1h-1zm4 0h1v1h-1zm-12 1h1v1h-1zm3 0h1v1h-1zm3 0h1v1h-1zm2 0h1v1h-1zm4 0h1v1h-1zM4 18h1v1H4zm1 0h1v1H5zm1 0h1v1H6zm1 0h1v1H7zm1 0h1v1H8zm1 0h1v1H9zm1 0h1v1h-1zm4 0h1v1h-1zm2 0h1v1h-1zm1 0h1v1h-1zm3 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm2 0h1v1h-1zM4 19h1v1H4zm6 0h1v1h-1zm3 0h1v1h-1zm4 0h1v1h-1zm2 0h1v1h-1zm1 0h1v1h-1zm3 0h1v1h-1zm1 0h1v1h-1zM4 20h1v1H4zm2 0h1v1H6zm1 0h1v1H7zm1 0h1v1H8zm2 0h1v1h-1zm2 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm3 0h1v1h-1zm2 0h1v1h-1zM4 21h1v1H4zm2 0h1v1H6zm1 0h1v1H7zm1 0h1v1H8zm2 0h1v1h-1zm3 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm2 0h1v1h-1zm1 0h1v1h-1zm5 0h1v1h-1zM4 22h1v1H4zm2 0h1v1H6zm1 0h1v1H7zm1 0h1v1H8zm2 0h1v1h-1zm2 0h1v1h-1zm2 0h1v1h-1zm1 0h1v1h-1zm2 0h1v1h-1zm2 0h1v1h-1zm5 0h1v1h-1zM4 23h1v1H4zm6 0h1v1h-1zm3 0h1v1h-1zm1 0h1v1h-1zm2 0h1v1h-1zm1 0h1v1h-1zm1 0h1v1h-1zm3 0h1v1h-1zm2 0h1v1h-1zm1 0h1v1h-1zM4 24h1v1H4zm1 0h1v1H5zm1 0h1v1H6zm1 0h1v1H7zm1 0h1v1H8zm1 0h1v1H9zm1 0h1v1h-1zm2 0h1v1h-1zm3 0h1v1h-1zm2 0h1v1h-1zm2 0h1v1h-1zm2 0h1v1h-1zm3 0h1v1h-1z\"/>\n</svg>\n"
  },
  {
    "path": "web/src/components/icons/CobaltSticker.svelte",
    "content": "<svg width=\"236\" height=\"70\" viewBox=\"0 0 236 70\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<rect width=\"235.5\" height=\"70\" rx=\"35\" fill=\"black\"/>\n<path d=\"M35 47.5683V42.9981L50.7927 35.4827L35 27.0024V22.4321L56.4293 34.1624V37.0061L35 47.5683Z\" fill=\"white\"/>\n<path d=\"M53.5713 47.5683V42.9981L69.3639 35.4827L53.5713 27.0024V22.4321L75.0006 34.1624V37.0061L53.5713 47.5683Z\" fill=\"white\"/>\n<path d=\"M102.105 45.86C100.945 45.86 99.9052 45.67 98.9852 45.29C98.0852 44.91 97.3152 44.37 96.6752 43.67C96.0552 42.97 95.5752 42.12 95.2352 41.12C94.9152 40.12 94.7552 39 94.7552 37.76C94.7552 36.52 94.9152 35.4 95.2352 34.4C95.5752 33.4 96.0552 32.55 96.6752 31.85C97.3152 31.15 98.0852 30.61 98.9852 30.23C99.9052 29.85 100.935 29.66 102.075 29.66C103.675 29.66 104.955 30 105.915 30.68C106.895 31.36 107.625 32.24 108.105 33.32L105.555 34.7C105.275 33.98 104.845 33.41 104.265 32.99C103.705 32.57 102.975 32.36 102.075 32.36C100.835 32.36 99.8752 32.74 99.1952 33.5C98.5152 34.24 98.1752 35.23 98.1752 36.47V39.05C98.1752 40.27 98.5152 41.26 99.1952 42.02C99.8752 42.78 100.855 43.16 102.135 43.16C103.095 43.16 103.875 42.94 104.475 42.5C105.095 42.06 105.585 41.46 105.945 40.7L108.345 42.17C107.845 43.25 107.085 44.14 106.065 44.84C105.045 45.52 103.725 45.86 102.105 45.86ZM119.523 45.86C118.383 45.86 117.353 45.67 116.433 45.29C115.533 44.91 114.763 44.37 114.123 43.67C113.503 42.97 113.023 42.12 112.683 41.12C112.343 40.12 112.173 39 112.173 37.76C112.173 36.52 112.343 35.4 112.683 34.4C113.023 33.4 113.503 32.55 114.123 31.85C114.763 31.15 115.533 30.61 116.433 30.23C117.353 29.85 118.383 29.66 119.523 29.66C120.663 29.66 121.683 29.85 122.583 30.23C123.503 30.61 124.273 31.15 124.893 31.85C125.533 32.55 126.023 33.4 126.363 34.4C126.703 35.4 126.873 36.52 126.873 37.76C126.873 39 126.703 40.12 126.363 41.12C126.023 42.12 125.533 42.97 124.893 43.67C124.273 44.37 123.503 44.91 122.583 45.29C121.683 45.67 120.663 45.86 119.523 45.86ZM119.523 43.28C120.723 43.28 121.673 42.92 122.373 42.2C123.093 41.46 123.453 40.37 123.453 38.93V36.59C123.453 35.15 123.093 34.07 122.373 33.35C121.673 32.61 120.723 32.24 119.523 32.24C118.323 32.24 117.363 32.61 116.643 33.35C115.943 34.07 115.593 35.15 115.593 36.59V38.93C115.593 40.37 115.943 41.46 116.643 42.2C117.363 42.92 118.323 43.28 119.523 43.28ZM130.882 23.3H134.152V32.66H134.332C134.792 31.72 135.402 30.99 136.162 30.47C136.922 29.93 137.872 29.66 139.012 29.66C140.832 29.66 142.282 30.34 143.362 31.7C144.442 33.06 144.982 35.08 144.982 37.76C144.982 40.44 144.442 42.46 143.362 43.82C142.282 45.18 140.832 45.86 139.012 45.86C137.872 45.86 136.922 45.6 136.162 45.08C135.402 44.54 134.792 43.8 134.332 42.86H134.152V45.5H130.882V23.3ZM137.662 43.19C138.902 43.19 139.852 42.81 140.512 42.05C141.192 41.29 141.532 40.28 141.532 39.02V36.5C141.532 35.24 141.192 34.23 140.512 33.47C139.852 32.71 138.902 32.33 137.662 32.33C137.182 32.33 136.732 32.39 136.312 32.51C135.892 32.63 135.522 32.81 135.202 33.05C134.882 33.29 134.622 33.59 134.422 33.95C134.242 34.29 134.152 34.7 134.152 35.18V40.34C134.152 40.82 134.242 41.24 134.422 41.6C134.622 41.94 134.882 42.23 135.202 42.47C135.522 42.71 135.892 42.89 136.312 43.01C136.732 43.13 137.182 43.19 137.662 43.19ZM161.26 45.5C160.32 45.5 159.61 45.26 159.13 44.78C158.67 44.3 158.39 43.67 158.29 42.89H158.14C157.84 43.83 157.29 44.56 156.49 45.08C155.71 45.6 154.71 45.86 153.49 45.86C151.91 45.86 150.65 45.45 149.71 44.63C148.77 43.79 148.3 42.64 148.3 41.18C148.3 39.68 148.85 38.54 149.95 37.76C151.07 36.96 152.78 36.56 155.08 36.56H158.05V35.36C158.05 33.26 156.94 32.21 154.72 32.21C153.72 32.21 152.91 32.41 152.29 32.81C151.67 33.19 151.15 33.7 150.73 34.34L148.78 32.75C149.22 31.91 149.96 31.19 151 30.59C152.04 29.97 153.38 29.66 155.02 29.66C156.98 29.66 158.52 30.13 159.64 31.07C160.76 32.01 161.32 33.37 161.32 35.15V42.92H163.27V45.5H161.26ZM154.42 43.46C155.48 43.46 156.35 43.22 157.03 42.74C157.71 42.26 158.05 41.64 158.05 40.88V38.63H155.14C152.78 38.63 151.6 39.34 151.6 40.76V41.36C151.6 42.06 151.85 42.59 152.35 42.95C152.85 43.29 153.54 43.46 154.42 43.46ZM166.738 42.86H171.868V25.94H166.738V23.3H175.138V42.86H180.268V45.5H166.738V42.86ZM192.407 45.5C191.047 45.5 190.047 45.14 189.407 44.42C188.767 43.68 188.447 42.73 188.447 41.57V32.66H183.677V30.02H187.067C187.647 30.02 188.057 29.91 188.297 29.69C188.537 29.45 188.657 29.03 188.657 28.43V24.56H191.717V30.02H198.317V32.66H191.717V42.86H198.317V45.5H192.407Z\" fill=\"white\"/>\n</svg>\n"
  },
  {
    "path": "web/src/components/icons/Imput.svelte",
    "content": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 532 283\">\n    <path fill=\"#fff\" d=\"M0 282.717v-62.5886h531.441v62.5886z\"/>\n    <path fill=\"#fff\" d=\"m103.176 35.2625 54.20331759-31.2943 139.958 242.41436693-54.20331759 31.2943z\"/>\n    <path fill=\"#fff\" d=\"m368.95 0 54.20331759 31.2943-134.6 233.1340387L234.35 233.1340387z\"/>\n</svg>\n"
  },
  {
    "path": "web/src/components/icons/Music.svelte",
    "content": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 32 32\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n    <path d=\"M14.3599 3.00421L8.21995 3.80421C7.89995 3.84421 7.65995 4.12421 7.65995 4.44421V12.0642C7.08995 11.8642 6.45995 11.7842 5.79995 11.8542C3.74995 12.0742 2.12995 13.7742 1.99995 15.8342C1.83995 18.3242 3.80995 20.3842 6.26995 20.3842C8.62995 20.3842 10.5499 18.4742 10.5499 16.1042C10.5499 16.0142 10.5499 15.9242 10.5399 15.8342V8.00421C10.5399 7.72421 10.7499 7.48421 11.0299 7.44421L14.4899 6.99421C14.7499 6.96421 14.9499 6.73421 14.9499 6.46421V3.53421C14.9599 3.21421 14.6799 2.96421 14.3599 3.00421Z\" fill=\"#7941A5\"/>\n    <path d=\"M29.4 5.37423L23.26 6.17423C22.94 6.21423 22.7 6.48423 22.7 6.80423V16.8142C22.13 16.6142 21.5 16.5342 20.84 16.6042C18.79 16.8242 17.17 18.5242 17.04 20.5842C16.88 23.0742 18.85 25.1342 21.31 25.1342C23.67 25.1342 25.59 23.2242 25.59 20.8542C25.59 20.7642 25.59 20.6742 25.58 20.5842V10.3742C25.58 10.0942 25.79 9.85423 26.07 9.81423L29.53 9.36423C29.8 9.32423 30 9.10424 30 8.83424V5.89423C30 5.57423 29.72 5.33423 29.4 5.37423Z\" fill=\"#7941A5\"/>\n    <path d=\"M13.09 10.6543L19.23 9.85429C19.55 9.80429 19.83 10.0543 19.83 10.3743V13.3043C19.83 13.5743 19.63 13.8043 19.37 13.8343L15.91 14.2843C15.63 14.3243 15.42 14.5643 15.42 14.8443V25.0643C15.43 25.1543 15.43 25.2443 15.43 25.3343C15.43 27.7043 13.51 29.6143 11.15 29.6143C8.68995 29.6143 6.71995 27.5543 6.87995 25.0643C6.99995 23.0043 8.61995 21.3143 10.67 21.0943C11.33 21.0243 11.96 21.1043 12.53 21.3043V11.2943C12.53 10.9643 12.77 10.6943 13.09 10.6543Z\" fill=\"#7941A5\"/>\n</svg>\n"
  },
  {
    "path": "web/src/components/icons/Mute.svelte",
    "content": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 32 32\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n    <path d=\"M7.80282 23H12.0122L13.0122 16L12.0122 9H7.80282C6.80707 9 6 9.84705 6 10.8921V21.1079C6 22.153 6.80707 23 7.80282 23ZM26 16.0232C26 17.7104 24.6322 19.0781 22.945 19.0781C21.2579 19.0781 19.8901 17.7104 19.8901 16.0232C19.8901 14.336 21.2579 12.9683 22.945 12.9683C24.6322 12.9683 26 14.336 26 16.0232Z\" fill=\"#8C8C8C\"/>\n    <path d=\"M20.6106 26.8309L11.9976 23.0011L11.9976 9.01942L20.0474 5.23153C21.1704 4.70349 23.0356 5.2552 23.0356 6.49651V25.3045C23.0356 26.5512 21.7343 27.3705 20.6106 26.8309Z\" fill=\"#C8C8C8\"/>\n    <path d=\"M24.9692 26.6519L5.1497 6.83167C4.68545 6.36742 4.68545 5.61418 5.1497 5.14994C5.61394 4.6857 6.36718 4.6857 6.83142 5.14994L26.6509 24.9694C27.1151 25.4337 27.1151 26.1869 26.6509 26.6511C26.1866 27.1161 25.4342 27.1161 24.9692 26.6519Z\" fill=\"#F8312F\"/>\n</svg>\n"
  },
  {
    "path": "web/src/components/icons/Sparkles.svelte",
    "content": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 32 32\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n    <path d=\"M10.5194 7.0517C10.2265 6.93064 9.99626 6.69861 9.88117 6.41614L8.929 4.25725C8.75112 3.91425 8.23842 3.91425 8.071 4.25725L7.11883 6.41614C6.99327 6.69861 6.76308 6.92055 6.48057 7.0517L5.26682 7.57629C4.91106 7.74779 4.91106 8.24212 5.26682 8.41362L6.48057 8.93821C6.77354 9.05927 7.00374 9.2913 7.11883 9.57377L8.071 11.7427C8.24888 12.0858 8.76158 12.0858 8.929 11.7427L9.88117 9.57377C10.0067 9.2913 10.2369 9.06936 10.5194 8.93821L11.7332 8.41362C12.0889 8.24212 12.0889 7.74779 11.7332 7.57629L10.5194 7.0517Z\" fill=\"#FFA514\"/>\n    <path d=\"M25.5744 13.5546C24.7045 13.1673 24.0166 12.4539 23.6525 11.5775L20.7897 4.81023C20.2637 3.72992 18.7363 3.72992 18.2103 4.81023L15.3475 11.5775C14.9733 12.4539 14.2854 13.1673 13.4256 13.5546L9.80419 15.1955C8.73194 15.7254 8.73194 17.2746 9.80419 17.8045L13.4256 19.4454C14.2955 19.8327 14.9834 20.5461 15.3475 21.4225L18.2103 28.1898C18.7363 29.2701 20.2637 29.2701 20.7897 28.1898L23.6525 21.4225C24.0267 20.5461 24.7146 19.8327 25.5744 19.4454L29.1958 17.8045C30.2681 17.2746 30.2681 15.7254 29.1958 15.1955L25.5744 13.5546Z\" fill=\"#FFA514\"/>\n    <path d=\"M8.2811 20.3304C8.44173 20.7222 8.73465 21.0258 9.10315 21.2021L10.6528 21.927C11.1157 22.1621 11.1157 22.8379 10.6528 23.073L9.10315 23.7979C8.73465 23.9742 8.44173 24.2876 8.2811 24.6696L7.05276 27.6474C6.82598 28.1175 6.17402 28.1175 5.94724 27.6474L4.7189 24.6696C4.55827 24.2778 4.26535 23.9742 3.89685 23.7979L2.34724 23.073C1.88425 22.8379 1.88425 22.1621 2.34724 21.927L3.89685 21.2021C4.26535 21.0258 4.55827 20.7124 4.7189 20.3304L5.94724 17.3526C6.17402 16.8825 6.82598 16.8825 7.05276 17.3526L8.2811 20.3304Z\" fill=\"#FFA514\"/>\n</svg>\n"
  },
  {
    "path": "web/src/components/misc/AboutPageWrapper.svelte",
    "content": "<!-- Workaround for https://github.com/pngwn/MDsveX/issues/116 -->\n<script lang=\"ts\" context=\"module\">\n    import a from \"$components/misc/OuterLink.svelte\";\n    export { a };\n</script>\n\n<div class=\"long-text about\">\n    <slot></slot>\n</div>\n"
  },
  {
    "path": "web/src/components/misc/BetaTesters.svelte",
    "content": "<script lang=\"ts\">\n    import OuterLink from \"./OuterLink.svelte\";\n\n    type Tester = { name: string, url?: string };\n    const credits: Tester[] = [\n        { name: \"codfish246\" },\n        { name: \"damir\", url: \"https://otomir23.me/\" },\n        { name: \"Hunter\" },\n        { name: \"hyperdefined\", url: \"https://hyper.lol/\" },\n        { name: \"KwiatekMiki\", url: \"https://kwiatekmiki.com/\" },\n        { name: \"Lao\", url: \"https://lao.ooo/\" },\n        { name: \"lostdusty\", url: \"https://lostdusty.dev.br/\" },\n        { name: \"noblereign\", url: \"https://fursona.directory/@frost\" },\n        { name: \"Spax\", url: \"https://spax.zone/\" },\n        { name: \"synzr\", url: \"https://synzr.space/\" },\n        { name: \"vimae\", url: \"https://mae.wtf/\" }\n    ];\n</script>\n\n<ul>\n    {#each credits as { name, url }}\n        <li>\n            {#if url}\n                <OuterLink href={url}>\n                    {name}\n                </OuterLink>\n            {:else}\n                {name}\n            {/if}\n        </li>\n    {/each}\n</ul>\n"
  },
  {
    "path": "web/src/components/misc/BulletExplain.svelte",
    "content": "<script lang=\"ts\">\n    export let title: string;\n    export let description: string;\n    export let icon: ConstructorOfATypedSvelteComponent;\n</script>\n\n<div class=\"bullet-holder\">\n    <div class=\"bullet-icon\">\n        <svelte:component this={icon} />\n    </div>\n    <div class=\"bullet-content\">\n        <div class=\"bullet-title\">\n            {title}\n        </div>\n        <div class=\"subtext bullet-description\">\n            {description}\n        </div>\n    </div>\n</div>\n\n<style>\n    .bullet-holder {\n        display: flex;\n        flex-direction: row;\n        text-align: start;\n        gap: var(--padding);\n    }\n\n    .bullet-content {\n        display: flex;\n        flex-direction: column;\n        gap: calc(var(--padding) / 2);\n    }\n\n    .bullet-title {\n        color: var(--secondary);\n        display: flex;\n        flex-direction: row;\n        align-items: center;\n        font-weight: 500;\n        gap: var(--padding);\n    }\n\n    .bullet-description {\n        padding: 0;\n        line-height: 1.5;\n        font-size: 13.5px;\n    }\n\n    .bullet-icon {\n        display: flex;\n    }\n\n    .bullet-icon :global(svg) {\n        width: 21px;\n        height: 21px;\n    }\n\n    @media screen and (max-width: 535px) {\n        .bullet-content {\n            gap: calc(var(--padding) / 2.5);\n        }\n\n        .bullet-title {\n            font-size: 15px;\n        }\n\n        .bullet-description {\n            font-size: 13px;\n        }\n\n        .bullet-icon :global(svg) {\n            width: 19px;\n            height: 19px;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/misc/CopyIcon.svelte",
    "content": "<script lang=\"ts\">\n    import IconLink from \"@tabler/icons-svelte/IconLink.svelte\";\n    import IconCopy from \"@tabler/icons-svelte/IconCopy.svelte\";\n    import IconCheck from \"@tabler/icons-svelte/IconCheck.svelte\";\n\n    export let check = false;\n    export let regularIcon = false;\n</script>\n\n<div class=\"copy-animation\" class:check>\n    <div class=\"icon-copy\">\n        {#if regularIcon}\n            <IconCopy />\n        {:else}\n            <IconLink />\n        {/if}\n    </div>\n    <div class=\"icon-check\">\n        <IconCheck />\n    </div>\n</div>\n\n<style>\n    .copy-animation {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        position: relative;\n        height: 24px;\n        width: 24px;\n    }\n\n    .copy-animation :global(svg) {\n        will-change: transform;\n    }\n\n    .icon-copy,\n    .icon-check {\n        display: flex;\n        position: absolute;\n        transition: transform 0.25s, opacity 0.25s;\n    }\n\n    .icon-copy {\n        transform: none;\n        opacity: 1;\n    }\n\n    .icon-check {\n        transform: scale(0.4);\n        opacity: 0;\n    }\n\n    .check .icon-copy {\n        transform: scale(0.4);\n        opacity: 0;\n    }\n\n    .check .icon-check {\n        transform: none;\n        opacity: 1;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/misc/DropReceiver.svelte",
    "content": "<script lang=\"ts\">\n    import type { Snippet } from \"svelte\";\n\n    type Props = {\n        id: string;\n        draggedOver?: boolean;\n        files: FileList | undefined;\n        onDrop: () => {};\n        children?: Snippet;\n    };\n\n    let {\n        id,\n        draggedOver = $bindable(false),\n        files = $bindable(),\n        onDrop,\n        children,\n    }: Props = $props();\n\n    const dropHandler = async (ev: DragEvent) => {\n        draggedOver = false;\n        ev.preventDefault();\n\n        if (ev?.dataTransfer?.files && ev?.dataTransfer?.files.length > 0) {\n            files = ev.dataTransfer.files;\n            onDrop();\n        }\n    };\n\n    const dragOverHandler = (ev: DragEvent) => {\n        draggedOver = true;\n        ev.preventDefault();\n    };\n</script>\n\n<div\n    {id}\n    role=\"region\"\n    ondrop={(ev) => dropHandler(ev)}\n    ondragover={(ev) => dragOverHandler(ev)}\n    ondragend={() => {\n        draggedOver = false;\n    }}\n    ondragleave={() => {\n        draggedOver = false;\n    }}\n>\n    {@render children?.()}\n</div>\n"
  },
  {
    "path": "web/src/components/misc/FileReceiver.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n\n    import Meowbalt from \"$components/misc/Meowbalt.svelte\";\n    import IconFileImport from \"@tabler/icons-svelte/IconFileImport.svelte\";\n    import IconUpload from \"@tabler/icons-svelte/IconUpload.svelte\";\n\n    type Props = {\n        files: FileList | undefined;\n        draggedOver?: boolean;\n        acceptTypes: string[];\n        acceptExtensions: string[];\n        maxFileNumber?: number;\n        onImport: () => {};\n    }\n\n    let {\n        files = $bindable(),\n        draggedOver = $bindable(false),\n        acceptTypes,\n        acceptExtensions,\n        maxFileNumber = 100,\n        onImport,\n    }: Props = $props();\n\n    let selectorStringMultiple = maxFileNumber > 1 ? \".multiple\" : \"\";\n\n    let fileInput: HTMLInputElement;\n\n    const openFile = async () => {\n        fileInput = document.createElement(\"input\");\n        fileInput.type = \"file\";\n        fileInput.accept = acceptTypes.join(\",\");\n\n        if (maxFileNumber > 1) {\n            fileInput.multiple = true;\n        }\n\n        fileInput.click();\n        fileInput.onchange = async () => {\n            let userFiles = fileInput?.files;\n            if (userFiles && userFiles.length >= 1) {\n                if (userFiles.length > maxFileNumber) {\n                    return alert(\"too many files, limit is \" + maxFileNumber);\n                }\n                files = userFiles;\n                onImport();\n            }\n        };\n    };\n</script>\n\n<div class=\"open-file-container\" class:dragged-over={draggedOver}>\n    <Meowbalt emotion=\"question\" />\n\n    <button class=\"button open-file-button\" onclick={openFile}>\n        <div class=\"dashed-stroke\">\n            <svg width=\"100%\" height=\"100%\" xmlns=\"http://www.w3.org/2000/svg\">\n                <rect width=\"100%\" height=\"100%\" fill=\"none\" rx=\"24\" ry=\"24\" />\n            </svg>\n        </div>\n\n        <div class=\"open-file-icon\">\n            {#if draggedOver}\n                <IconUpload />\n            {:else}\n                <IconFileImport />\n            {/if}\n        </div>\n\n        <div class=\"open-file-text\">\n            <div class=\"open-title\">\n                {#if draggedOver}\n                    {$t(\"receiver.title.drop\" + selectorStringMultiple)}\n                {:else}\n                    {$t(\"receiver.title\" + selectorStringMultiple)}\n                {/if}\n            </div>\n            <div class=\"subtext accept-list\">\n                {$t(\"receiver.accept\", {\n                    formats: acceptExtensions.join(\", \"),\n                })}\n            </div>\n        </div>\n    </button>\n</div>\n\n<style>\n    .open-file-button {\n        position: relative;\n        flex-direction: column;\n        gap: 4px;\n        padding: 26px 28px;\n        transition: box-shadow 0.2s;\n    }\n\n    .open-file-button {\n        box-shadow: none;\n    }\n\n    .open-file-button,\n    .dashed-stroke :global(svg) {\n        border-radius: 24px;\n    }\n\n    .dragged-over .open-file-button {\n        background-image: none;\n        box-shadow: 0 0 50px 10px var(--button-hover);\n    }\n\n    .open-file-container {\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n    }\n\n    .dashed-stroke {\n        position: absolute;\n        height: 100%;\n        width: 100%;\n        bottom: 0;\n        pointer-events: none;\n    }\n\n    .dashed-stroke :global(svg rect) {\n        width: 100%;\n        height: 100%;\n        stroke-width: 5;\n        stroke-dashoffset: 3;\n        stroke-linecap: square;\n        stroke-dasharray: 10, 15;\n        stroke: var(--input-border);\n        transition:\n            stroke-dasharray 0.2s,\n            stroke-dashoffset 0.2s;\n    }\n\n    .dragged-over .dashed-stroke :global(svg rect),\n    .open-file-button:focus-visible .dashed-stroke :global(svg rect) {\n        stroke-dasharray: 20, 5;\n        stroke-dashoffset: 8;\n    }\n\n    .open-file-button:focus-visible .dashed-stroke :global(svg rect) {\n        stroke: var(--blue);\n    }\n\n    .open-file-button:focus-visible {\n        outline: none;\n    }\n\n    .open-file-container :global(.meowbalt) {\n        z-index: 2;\n        clip-path: inset(0px 0px 16px 0px);\n        margin-bottom: -16px;\n        transition:\n            clip-path 0.2s,\n            margin-bottom 0.2s,\n            opacity 0.15s;\n    }\n\n    .dragged-over :global(.meowbalt) {\n        clip-path: inset(0px 0px 9px 0px);\n        margin-bottom: -9px;\n    }\n\n    .open-file-icon {\n        display: flex;\n    }\n\n    .open-file-icon :global(svg) {\n        width: 32px;\n        height: 32px;\n        stroke-width: 1.8px;\n    }\n\n    .open-file-text {\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n        gap: 6px;\n        max-width: 300px;\n    }\n\n    .open-title {\n        font-size: 18px;\n    }\n\n    .accept-list {\n        max-width: 250px;\n        font-size: 14px;\n        padding: 0;\n        user-select: none;\n        -webkit-user-select: none;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/misc/Meowbalt.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n    import type { MeowbaltEmotions } from \"$lib/types/meowbalt\";\n\n    type Props = {\n        emotion: MeowbaltEmotions;\n        forceLoaded?: boolean;\n    };\n\n    const { emotion, forceLoaded }: Props = $props();\n\n    let loaded = $state(false);\n</script>\n\n<img\n    class=\"meowbalt {emotion}\"\n    class:loaded={loaded || forceLoaded}\n    onload={() => (loaded = true)}\n    src=\"/meowbalt/{emotion}.png\"\n    height=\"152\"\n    alt={$t(\"general.meowbalt\")}\n    aria-hidden=\"true\"\n/>\n\n<style>\n    .meowbalt {\n        display: block;\n        margin: 0;\n        object-fit: cover;\n        opacity: 0;\n        transition: opacity 0.15s;\n    }\n\n    .meowbalt.loaded {\n        opacity: 1;\n    }\n\n    .error {\n        height: 160px;\n    }\n\n    .question {\n        height: 140px;\n    }\n\n    .error {\n        margin-left: 25px;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/misc/NotchSticker.svelte",
    "content": "<script lang=\"ts\">\n    import { onMount } from \"svelte\";\n\n    import CobaltSticker from \"$components/icons/CobaltSticker.svelte\";\n\n    // please add a source link (https://github.com/imputnet/cobalt) if you use this implementation\n    // i spent 4 hours switching between simulators and devices to get the best way to do this\n\n    $: safeAreaTop = 0;\n    $: safeAreaBottom = 0;\n    $: state = \"hidden\"; // \"notch\", \"island\", \"notch x\"\n\n    const islandValues = [\n        53, // 16 pro max: larger text\n        59, // regular & plus: default\n        48, // regular: larger text\n        49, // 16: larger text\n        51, // plus only: larger text\n        62, // 16: regular\n    ];\n\n    const xNotch = [44];\n\n    const getSafeAreaTop = () => {\n        const root = document.documentElement;\n        return getComputedStyle(root)\n            .getPropertyValue(\"--safe-area-inset-top\")\n            .trim();\n    };\n\n    const getSafeAreaBottom = () => {\n        const root = document.documentElement;\n        return getComputedStyle(root)\n            .getPropertyValue(\"--safe-area-inset-bottom\")\n            .trim();\n    };\n\n    onMount(() => {\n        safeAreaTop = Number(getSafeAreaTop().replace(\"px\", \"\"));\n        safeAreaBottom = Number(getSafeAreaBottom().replace(\"px\", \"\"));\n    });\n\n    $: if (safeAreaTop > 20) {\n        state = \"notch\";\n        if (islandValues.includes(safeAreaTop)) {\n            state = \"island\";\n        }\n        if (xNotch.includes(safeAreaTop)) {\n            state = \"notch x\";\n        }\n        // exception for XR and 11 at regular screen zoom\n        if (safeAreaTop === 48 && safeAreaBottom === 34) {\n            state = \"notch\";\n        }\n\n        // exception for iPhone 16 Pro Max\n        if (safeAreaTop === 53 && safeAreaBottom === 29) {\n            state = \"notch sixteen-pro-max\";\n        }\n    }\n</script>\n\n{#if state !== \"hidden\"}\n    <div id=\"cobalt-notch-sticker\" aria-hidden=\"true\" class={state}>\n        <CobaltSticker />\n    </div>\n{/if}\n\n<style>\n    #cobalt-notch-sticker {\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        position: absolute;\n        top: 0;\n        width: 100%;\n        z-index: 999;\n    }\n\n    #cobalt-notch-sticker.island {\n        padding-top: 15px;\n    }\n\n    #cobalt-notch-sticker.notch {\n        padding-top: 2px;\n    }\n\n    #cobalt-notch-sticker.sixteen-pro-max {\n        padding-top: 12px;\n    }\n\n    #cobalt-notch-sticker.notch.x :global(svg) {\n        height: 28px;\n    }\n\n    #cobalt-notch-sticker :global(svg) {\n        width: 100px;\n        height: 30px;\n    }\n\n    /* regular iphone size, larger text display mode */\n    @media screen and (max-width: 350px) {\n        #cobalt-notch-sticker.notch :global(svg) {\n            height: 24px;\n        }\n\n        #cobalt-notch-sticker.island {\n            padding-top: 9px;\n        }\n    }\n\n    /* regular & plus iphone size, dynamic island, larger text display mode */\n    @media screen and (max-width: 375px) {\n        #cobalt-notch-sticker.island :global(svg) {\n            height: 26px;\n        }\n\n        #cobalt-notch-sticker.island {\n            padding-top: 11px;\n        }\n    }\n\n    @media screen and (orientation: landscape) {\n        #cobalt-notch-sticker {\n            display: none;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/misc/OuterLink.svelte",
    "content": "<script lang=\"ts\">\n    export let href: string;\n\n    // rel is passed by MDsveX, but we don't need it, so we just ignore it\n    // no way to change this behavior atm (https://github.com/pngwn/MDsveX/issues/609)\n    export let rel: string = \"\";\n    rel;\n\n    const [ target, _rel ] = (() => {\n        try {\n            new URL(href)\n            return [ '_blank', 'noopener noreferrer' ];\n        } catch {}\n\n        return [];\n    })();\n</script>\n\n<a rel={_rel} {target} {href}>\n    <slot></slot>\n</a>\n"
  },
  {
    "path": "web/src/components/misc/Placeholder.svelte",
    "content": "<script>\n    import Meowbalt from \"$components/misc/Meowbalt.svelte\";\n\n    export let pageName;\n</script>\n\n<div id=\"placeholder-container\" class=\"center-column-container\">\n    <Meowbalt emotion=\"smile\" />\n    <div tabindex=\"-1\" data-first-focus>\n        {`${pageName} page is not ready yet!`}\n    </div>\n</div>\n\n<style>\n    #placeholder-container {\n        gap: calc(var(--padding) * 2);\n        text-align: center;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/misc/PopoverContainer.svelte",
    "content": "<script lang=\"ts\">\n    export let id = \"\";\n    export let expanded = false;\n    export let expandStart: \"left\" | \"center\" | \"right\" = \"center\";\n\n    /*\n        a popover isn't pre-rendered by default, because the user might never open it.\n        but if they do, we render only once, and then keep it the dom :3\n    */\n\n    $: renderPopover = false;\n    $: if (expanded && !renderPopover) renderPopover = true;\n</script>\n\n<div {id} class=\"popover {expandStart}\" aria-hidden={!expanded} class:expanded>\n    {#if renderPopover}\n        <slot></slot>\n    {/if}\n</div>\n\n<style>\n    .popover {\n        display: flex;\n        flex-direction: column;\n        border-radius: 18px;\n        background: var(--button);\n        box-shadow: var(--button-box-shadow);\n\n        filter: drop-shadow(0 0 8px var(--popover-glow))\n            drop-shadow(0 0 10px var(--popover-glow));\n        position: relative;\n        padding: var(--padding);\n        gap: 6px;\n        top: 6px;\n        z-index: 2;\n\n        opacity: 0;\n        transform: scale(0);\n        transform-origin: top center;\n\n        transition:\n            transform 0.3s cubic-bezier(0.53, 0.05, 0.23, 1.15),\n            opacity 0.25s cubic-bezier(0.53, 0.05, 0.23, 0.99);\n\n        will-change: transform, opacity;\n\n        pointer-events: all;\n    }\n\n    .popover.left {\n        transform-origin: top left;\n    }\n\n    :global([dir=\"rtl\"]) .popover.left {\n        transform-origin: top right;\n    }\n\n    .popover.center {\n        transform-origin: top center;\n    }\n\n    .popover.right {\n        transform-origin: top right;\n    }\n\n    :global([dir=\"rtl\"]) .popover.right {\n        transform-origin: top left;\n    }\n\n    .popover.expanded {\n        opacity: 1;\n        transform: none;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/misc/SectionHeading.svelte",
    "content": "<script lang=\"ts\">\n    import { page } from \"$app/state\";\n    import { copyURL } from \"$lib/download\";\n    import { t } from \"$lib/i18n/translations\";\n    import { hapticConfirm } from \"$lib/haptics\";\n\n    import CopyIcon from \"$components/misc/CopyIcon.svelte\";\n\n    type Props = {\n        title: string;\n        sectionId: string;\n        beta?: boolean;\n        nolink?: boolean;\n        copyData?: string;\n    };\n\n    let {\n        title,\n        sectionId,\n        beta = false,\n        nolink = false,\n        copyData = \"\",\n    }: Props = $props();\n\n    const sectionURL = `${page.url.origin}${page.url.pathname}#${sectionId}`;\n\n    let copied = $state(false);\n</script>\n\n<div class=\"heading-container\">\n    <h3 id=\"{sectionId}-title\" class=\"content-title\">\n        {title}\n    </h3>\n\n    {#if beta}\n        <div class=\"beta-label\">\n            {$t(\"general.beta\")}\n        </div>\n    {/if}\n\n    {#if !nolink}\n        <button\n            class=\"link-copy\"\n            aria-label={copied\n                ? $t(\"button.copied\")\n                : $t(`button.copy${copyData ? \"\" : \".section\"}`)}\n            onclick={() => {\n                if (!copied) {\n                    copyURL(copyData || sectionURL);\n                    hapticConfirm();\n                    copied = true;\n                    setTimeout(() => {\n                        copied = false;\n                    }, 1500);\n                }\n            }}\n        >\n            <CopyIcon check={copied} regularIcon={!!copyData} />\n        </button>\n    {/if}\n</div>\n\n<style>\n    .heading-container {\n        display: flex;\n        flex-direction: row;\n        flex-wrap: wrap-reverse;\n        gap: 6px;\n        justify-content: start;\n        align-items: center;\n        box-shadow: none;\n    }\n\n    .link-copy {\n        background: transparent;\n        padding: 2px;\n        box-shadow: none;\n        border-radius: 5px;\n        transition: opacity 0.2s;\n        opacity: 0.7;\n    }\n\n    .link-copy:focus-visible {\n        opacity: 1;\n        outline-offset: 0;\n    }\n\n    .link-copy :global(.copy-animation) {\n        width: 17px;\n        height: 17px;\n    }\n\n    .link-copy :global(.copy-animation *) {\n        width: 17px;\n        height: 17px;\n    }\n\n    .beta-label {\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        border-radius: 5px;\n        padding: 0 5px;\n        background: var(--secondary);\n        color: var(--primary);\n        font-size: 11px;\n        font-weight: 500;\n        line-height: 1.86;\n        text-transform: uppercase;\n    }\n\n    @media (hover: hover) {\n        .heading-container:hover .link-copy {\n            opacity: 1;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/misc/Skeleton.svelte",
    "content": "<script lang=\"ts\">\n    import type { Optional } from \"$lib/types/generic\";\n\n    export let width: Optional<string> = undefined;\n    export let height: Optional<string> = undefined;\n    export let hidden: Optional<boolean> = undefined;\n\n    let _class = '';\n    export { _class as class };\n\n    $: style = [\n        width && `width: ${width}`,\n        height && `height: ${height}`\n    ].filter(a => a).join(';');\n</script>\n\n{#if hidden !== true}\n<div\n    class=\"skeleton {_class}\"\n    {style}\n    {...$$restProps}\n></div>\n{/if}\n\n<style>\n    .skeleton {\n        border-radius: calc(var(--border-radius) / 2);\n        background-color: var(--button);\n        background-image: var(--skeleton-gradient);\n        background-size: 200px 100%;\n        background-repeat: no-repeat;\n        display: inline-flex;\n        justify-content: center;\n        align-items: center;\n        animation: skeleton 1.2s ease-in-out infinite;\n        line-height: 1;\n        font-size: 1em;\n        text-align: center;\n        pointer-events: none;\n    }\n\n    :global([data-theme=light]) .skeleton {\n        background-color: var(--button-hover);\n    }\n\n    .skeleton.elevated {\n        background-image: var(--skeleton-gradient-elevated);\n        background-color: var(--button-elevated);\n    }\n\n    :global([data-reduce-motion=\"true\"]) .skeleton {\n        background-image: none;\n    }\n\n    .skeleton.big {\n        border-radius: var(--border-radius);\n        background-size: 400px 100%;\n        animation: skeleton-big 1.2s ease-in-out infinite;\n    }\n\n    @keyframes skeleton {\n        0% {\n            background-position: -200px 0;\n        }\n        100% {\n            background-position: calc(200px + 100%) 0;\n        }\n    }\n\n    @keyframes skeleton-big {\n        0% {\n            background-position: -400px 0;\n        }\n        100% {\n            background-position: calc(400px + 100%) 0;\n        }\n    }\n</style>"
  },
  {
    "path": "web/src/components/misc/Toggle.svelte",
    "content": "<script lang=\"ts\">\n    export let enabled: boolean;\n</script>\n\n<div class=\"toggle\" class:enabled>\n    <div class=\"toggle-switcher\"></div>\n</div>\n\n<style>\n    .toggle {\n        --base-size: 22px;\n        --ratio-factor: 0.9;\n        --enabled-pos: calc(100% * var(--ratio-factor));\n\n        display: flex;\n        justify-content: start;\n        align-items: center;\n        min-width: calc(var(--base-size) * (1 + var(--ratio-factor)));\n        padding: 2px;\n        aspect-ratio: 2/1;\n        border-radius: 5px;\n        border-radius: 100px;\n        background: var(--toggle-bg);\n        transition: background 0.25s;\n    }\n\n    .toggle:dir(rtl) {\n        --enabled-pos: calc(-100% * var(--ratio-factor));\n    }\n\n    .toggle-switcher {\n        height: var(--base-size);\n        width: var(--base-size);\n        background: var(--white);\n        border-radius: 100px;\n        transform: translateX(0%);\n        transition: transform 0.25s cubic-bezier(0.53, 0.05, 0.02, 1.2);\n    }\n\n    .toggle.enabled {\n        background: var(--toggle-bg-enabled);\n    }\n\n    .toggle.enabled .toggle-switcher {\n        transform: translateX(var(--enabled-pos));\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/misc/Turnstile.svelte",
    "content": "<script lang=\"ts\">\n    import { onMount } from \"svelte\";\n\n    import cachedInfo from \"$lib/state/server-info\";\n    import { turnstileSolved, turnstileCreated } from \"$lib/state/turnstile\";\n\n    import turnstile from \"$lib/api/turnstile\";\n\n    let turnstileElement: HTMLElement;\n    let turnstileScript: HTMLElement;\n\n    onMount(() => {\n        const sitekey = $cachedInfo?.info?.cobalt?.turnstileSitekey;\n        if (!sitekey) return;\n\n        $turnstileCreated = true;\n\n        const setup = () => {\n            window.turnstile?.render(turnstileElement, {\n                sitekey,\n                \"refresh-expired\": \"never\",\n                \"retry-interval\": 800,\n\n                \"error-callback\": (error) => {\n                    console.log(\"error code from turnstile:\", error);\n                    return true;\n                },\n                \"expired-callback\": () => {\n                    console.log(\"turnstile expired, refreshing neow\");\n                    turnstile.reset();\n                },\n                callback: () => {\n                    $turnstileSolved = true;\n                }\n            });\n        }\n\n        if (window.turnstile) {\n            setup();\n        } else {\n            turnstileScript.addEventListener(\"load\", setup);\n        }\n    });\n</script>\n\n<svelte:head>\n    <script\n        bind:this={turnstileScript}\n        src=\"https://challenges.cloudflare.com/turnstile/v0/api.js\"\n        defer\n    ></script>\n</svelte:head>\n\n<div id=\"turnstile-container\">\n    <div bind:this={turnstileElement} id=\"turnstile-widget\"></div>\n</div>\n\n<style>\n    #turnstile-container {\n        position: absolute;\n        z-index: 999;\n        right: 0;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/misc/UpdateNotification.svelte",
    "content": "<script lang=\"ts\">\n    import { onMount } from \"svelte\";\n    import { t } from \"$lib/i18n/translations\";\n    import IconComet from \"@tabler/icons-svelte/IconComet.svelte\";\n\n    let dismissed = true;\n\n    onMount(() => {\n        setTimeout(() => {\n            dismissed = false;\n        }, 200)\n    });\n</script>\n\n<div id=\"update-notification\" role=\"alert\" aria-atomic=\"true\">\n    <button\n        class=\"button update-button\"\n        class:visible={!dismissed}\n        on:click={() => {\n            dismissed = true;\n            window.location.reload()\n        }}\n    >\n        <div class=\"update-icon\">\n            <IconComet />\n        </div>\n        <div class=\"update-text\">\n            <div>{$t(\"notification.update.title\")}</div>\n            <div class=\"subtext\">{$t(\"notification.update.subtext\")}</div>\n        </div>\n    </button>\n</div>\n\n<style>\n    #update-notification {\n        position: absolute;\n        display: flex;\n        justify-content: end;\n        align-items: center;\n        width: 100%;\n        pointer-events: none;\n        z-index: 2;\n    }\n\n    .update-button {\n        padding: 8px 12px 8px 8px;\n        pointer-events: all;\n        gap: 8px;\n        margin: 0 64px;\n        margin-top: calc(env(safe-area-inset-top) + 8px);\n        box-shadow:\n            var(--button-box-shadow),\n            0 0 10px 0px var(--button-elevated-hover);\n        border-radius: 14px;\n\n        transform: translateY(-150px);\n        transition: transform 0.4s cubic-bezier(0.53, 0.05, 0.23, 1.15);\n    }\n\n    .update-button.visible {\n        transform: none;\n    }\n\n    .update-icon {\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        background-color: var(--button-elevated-hover);\n        padding: 3px;\n        border-radius: 6px;\n    }\n\n    .update-icon :global(svg) {\n        stroke-width: 1.5px;\n        width: 25px;\n        height: 25px;\n        will-change: transform;\n    }\n\n    .update-text {\n        display: flex;\n        flex-direction: column;\n        text-align: start;\n        font-size: 12.5px;\n    }\n\n    .subtext {\n        padding: 0;\n        user-select: none;\n        -webkit-user-select: none;\n    }\n\n    .update-text,\n    .subtext {\n        line-height: 1.2;\n    }\n\n    @media screen and (max-width: 535px) {\n        #update-notification {\n            bottom: calc(var(--sidebar-height-mobile) + 16px);\n            justify-content: center;\n        }\n\n        .update-button {\n            transform: translateY(300px);\n            margin: 0;\n            transition: transform 0.55s cubic-bezier(0.53, 0.05, 0.23, 1.15);\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/queue/ProcessingQueue.svelte",
    "content": "<script lang=\"ts\">\n    import { onMount } from \"svelte\";\n    import { t } from \"$lib/i18n/translations\";\n    import { beforeNavigate, onNavigate } from \"$app/navigation\";\n\n    import { clearFileStorage } from \"$lib/storage/opfs\";\n\n    import { getProgress } from \"$lib/task-manager/queue\";\n    import { queueVisible } from \"$lib/state/queue-visibility\";\n    import { currentTasks } from \"$lib/state/task-manager/current-tasks\";\n    import { clearQueue, queue as readableQueue } from \"$lib/state/task-manager/queue\";\n\n    import SectionHeading from \"$components/misc/SectionHeading.svelte\";\n    import PopoverContainer from \"$components/misc/PopoverContainer.svelte\";\n    import ProcessingStatus from \"$components/queue/ProcessingStatus.svelte\";\n    import ProcessingQueueItem from \"$components/queue/ProcessingQueueItem.svelte\";\n    import ProcessingQueueStub from \"$components/queue/ProcessingQueueStub.svelte\";\n\n    import IconX from \"@tabler/icons-svelte/IconX.svelte\";\n\n    const popoverAction = () => {\n        $queueVisible = !$queueVisible;\n    };\n\n    let queue = $derived(Object.entries($readableQueue));\n\n    let totalProgress = $derived(queue.length ? queue.map(\n        ([, item]) => getProgress(item, $currentTasks) * 100\n    ).reduce((a, b) => a + b) / (100 * queue.length) : 0);\n\n    let indeterminate = $derived(queue.length > 0 && totalProgress === 0);\n\n    onNavigate(() => {\n        $queueVisible = false;\n    });\n\n    onMount(() => {\n        // clear old files from storage on first page load\n        clearFileStorage();\n    });\n\n    beforeNavigate((event) => {\n        if (event.type === \"leave\" && (totalProgress > 0 && totalProgress < 1)) {\n            event.cancel();\n        }\n    });\n</script>\n\n<div id=\"processing-queue\">\n    <ProcessingStatus\n        progress={totalProgress * 100}\n        {indeterminate}\n        expandAction={popoverAction}\n    />\n\n    <PopoverContainer\n        id=\"processing-popover\"\n        expanded={$queueVisible}\n        expandStart=\"right\"\n    >\n        <div id=\"processing-header\">\n            <div class=\"header-top\">\n                <SectionHeading\n                    title={$t(\"queue.title\")}\n                    sectionId=\"queue\"\n                    beta\n                    nolink\n                />\n                <div class=\"header-buttons\">\n                    {#if queue.length}\n                        <button\n                            class=\"clear-button\"\n                            onclick={clearQueue}\n                            tabindex={!$queueVisible ? -1 : undefined}\n                        >\n                            <IconX />\n                            {$t(\"button.clear\")}\n                        </button>\n                    {/if}\n                </div>\n            </div>\n        </div>\n\n        <div id=\"processing-list\" role=\"list\" aria-labelledby=\"queue-title\">\n            {#each queue as [id, item]}\n                <ProcessingQueueItem {id} info={item} />\n            {/each}\n            {#if queue.length === 0}\n                <ProcessingQueueStub />\n            {/if}\n        </div>\n    </PopoverContainer>\n</div>\n\n<style>\n    #processing-queue {\n        --holder-padding: 12px;\n        position: absolute;\n        right: 0;\n        display: flex;\n        flex-direction: column;\n        align-items: flex-end;\n        justify-content: end;\n        z-index: 9;\n        pointer-events: none;\n        padding: var(--holder-padding);\n        width: calc(100% - var(--holder-padding) * 2);\n    }\n\n    #processing-queue :global(#processing-popover) {\n        gap: 12px;\n        padding: 16px;\n        padding-bottom: 0;\n        width: calc(100% - 16px * 2);\n        max-width: 425px;\n    }\n\n    #processing-header {\n        display: flex;\n        flex-direction: column;\n        flex-wrap: wrap;\n        gap: 3px;\n    }\n\n    .header-top {\n        display: flex;\n        flex-direction: row;\n        justify-content: space-between;\n        align-items: center;\n        flex-wrap: wrap;\n        gap: 6px;\n    }\n\n    .header-buttons {\n        display: flex;\n        flex-direction: row;\n        gap: var(--padding);\n    }\n\n    .header-buttons button {\n        font-size: 13px;\n        font-weight: 500;\n        padding: 0;\n        background: none;\n        box-shadow: none;\n        text-align: left;\n        border-radius: 3px;\n        outline-offset: 5px;\n    }\n\n    .header-buttons button :global(svg) {\n        height: 16px;\n        width: 16px;\n    }\n\n    .clear-button {\n        color: var(--medium-red);\n    }\n\n    #processing-list {\n        display: flex;\n        flex-direction: column;\n        max-height: 65vh;\n        overflow-y: scroll;\n        overflow-x: hidden;\n    }\n\n    @media screen and (max-width: 535px) {\n        #processing-queue {\n            --holder-padding: 8px;\n            padding-top: 4px;\n            top: env(safe-area-inset-top);\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/queue/ProcessingQueueItem.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n    import { formatFileSize } from \"$lib/util\";\n    import { downloadFile } from \"$lib/download\";\n    import { getProgress } from \"$lib/task-manager/queue\";\n    import { savingHandler } from \"$lib/api/saving-handler\";\n\n    import { removeItem } from \"$lib/state/task-manager/queue\";\n    import { queueVisible } from \"$lib/state/queue-visibility\";\n    import { currentTasks } from \"$lib/state/task-manager/current-tasks\";\n\n    import type { CobaltQueueItem, UUID } from \"$lib/types/queue\";\n    import type { CobaltCurrentTasks } from \"$lib/types/task-manager\";\n\n    import ProgressBar from \"$components/queue/ProgressBar.svelte\";\n\n    import IconX from \"@tabler/icons-svelte/IconX.svelte\";\n    import IconCheck from \"@tabler/icons-svelte/IconCheck.svelte\";\n    import IconReload from \"@tabler/icons-svelte/IconReload.svelte\";\n    import IconLoader2 from \"@tabler/icons-svelte/IconLoader2.svelte\";\n    import IconDownload from \"@tabler/icons-svelte/IconDownload.svelte\";\n    import IconExclamationCircle from \"@tabler/icons-svelte/IconExclamationCircle.svelte\";\n\n    import IconFile from \"@tabler/icons-svelte/IconFile.svelte\";\n    import IconMovie from \"@tabler/icons-svelte/IconMovie.svelte\";\n    import IconMusic from \"@tabler/icons-svelte/IconMusic.svelte\";\n    import IconPhoto from \"@tabler/icons-svelte/IconPhoto.svelte\";\n\n    const itemIcons = {\n        file: IconFile,\n        video: IconMovie,\n        audio: IconMusic,\n        image: IconPhoto,\n    };\n\n    type Props = {\n        id: UUID;\n        info: CobaltQueueItem;\n    }\n\n    let { id, info }: Props = $props();\n\n    let retrying = $state(false);\n    let downloading = $state(false);\n\n    const retry = async (info: CobaltQueueItem) => {\n        if (info.canRetry && info.originalRequest) {\n            retrying = true;\n            await savingHandler({\n                request: info.originalRequest,\n                oldTaskId: id,\n            });\n            retrying = false;\n        }\n    };\n\n    const download = (file: File) => {\n        downloading = true;\n\n        downloadFile({\n            file: new File([file], info.filename, {\n                type: info.mimeType,\n            }),\n        });\n\n        setTimeout(() => {\n            /*\n                fake timeout to prevent download button spam,\n                because there's no real way to await the real\n                saving process via object url (blob), which\n                takes some time on some devices depending on file size.\n                if you know of a way to do it in\n                lib/download.ts -> openFile(), please make a PR!\n            */\n            downloading = false;\n        }, 3000)\n    };\n\n    type StatusText = {\n        info: CobaltQueueItem;\n        currentTasks: CobaltCurrentTasks;\n        retrying: boolean;\n    };\n\n    const generateStatusText = ({ info, retrying, currentTasks }: StatusText) => {\n        switch (info.state) {\n        case \"running\":\n            const progress = getProgress(info, currentTasks);\n\n            const runningWorkers = info.pipeline.filter(w => w.workerId in currentTasks);\n            const running = new Set(runningWorkers.map(task => task.worker));\n            const progresses = runningWorkers.map(w => currentTasks[w.workerId])\n                                            .map(t => t.progress)\n                                            .filter(p => p);\n\n            let totalSize = progresses.reduce((s, p) => s + (p?.size ?? 0), 0);\n\n            // if only fetch workers are running, then we should\n            // show the sum of all running & completed fetch workers\n            if (running.size === 1 && running.has(\"fetch\")) {\n                totalSize += Object.values(info.pipelineResults)\n                                   .reduce((s, p) => s + (p?.size ?? 0), 0);\n            }\n\n            const runningText = [...running].map(task => $t(`queue.state.running.${task}`)).join(\", \");\n\n            if (runningWorkers.length && totalSize > 0) {\n                const formattedSize = formatFileSize(totalSize);\n                return `${runningText}: ${Math.floor(progress * 100)}%, ${formattedSize}`;\n            }\n\n            const firstUnstarted = info.pipeline.find(w => {\n                if (info.pipelineResults[w.workerId])\n                    return false;\n\n                const task = currentTasks[w.workerId];\n                if (!task || !task.progress) {\n                    return true;\n                }\n            });\n\n            if (firstUnstarted) {\n                return $t(`queue.state.starting.${firstUnstarted.worker}`);\n            }\n\n            return runningText;\n\n        case \"done\":\n            return formatFileSize(info.resultFile?.size);\n\n        case \"error\":\n            return !retrying ? $t(`error.${info.errorCode}`) : $t(\"queue.state.retrying\");\n\n        case \"waiting\":\n            return $t(\"queue.state.waiting\");\n        }\n    };\n\n    const getWorkerProgress = (item: CobaltQueueItem, workerId: UUID): number | undefined => {\n        if (item.state === 'running' && item.pipelineResults[workerId]) {\n            return 100;\n        }\n\n        const workerIndex = item.pipeline.findIndex(w => w.workerId === workerId);\n        if (workerIndex === -1) {\n            return;\n        }\n\n        const worker = item.pipeline[workerIndex];\n        const task = $currentTasks[worker.workerId];\n        if (task?.progress?.percentage) {\n            return Math.max(0, Math.min(100, task.progress.percentage));\n        }\n    }\n\n    /*\n        params are passed here because svelte will re-run\n        the function every time either of them is changed,\n        which is what we want in this case :3\n    */\n    let statusText = $derived(generateStatusText({\n        info,\n        retrying,\n        currentTasks: $currentTasks\n    }));\n\n    const MediaTypeIcon = $derived(itemIcons[info.mediaType]);\n</script>\n\n<!-- svelte-ignore a11y_no_noninteractive_tabindex -->\n<div\n    class=\"processing-item\"\n    role=\"listitem\"\n    tabindex={$queueVisible ? 0 : -1}\n    class:queue-hidden={!$queueVisible}\n>\n    <div class=\"processing-info\">\n        <div class=\"file-title\">\n            <div class=\"processing-type\">\n                <MediaTypeIcon />\n            </div>\n            <span class=\"filename\">\n                {info.filename}\n            </span>\n        </div>\n\n        {#if info.state === \"running\"}\n            <div class=\"progress-holder\">\n                {#each info.pipeline as task}\n                    <ProgressBar\n                        percentage={getWorkerProgress(info, task.workerId) || 0}\n                        workerId={task.workerId}\n                        pipelineResults={info.pipelineResults}\n                    />\n                {/each}\n            </div>\n        {/if}\n\n        <div class=\"file-status {info.state}\" class:retrying>\n            <div class=\"status-icon\">\n                {#if info.state === \"done\"}\n                    <IconCheck />\n                {/if}\n                {#if info.state === \"error\" && !retrying}\n                    <IconExclamationCircle />\n                {/if}\n                {#if info.state === \"running\" || retrying}\n                    <div class=\"status-spinner\">\n                        <IconLoader2 />\n                    </div>\n                {/if}\n            </div>\n\n            <div class=\"status-text\">\n                {statusText}\n            </div>\n        </div>\n    </div>\n\n    <div class=\"file-actions\">\n        {#if info.state === \"done\" && info.resultFile}\n            <button\n                class=\"button action-button\"\n                aria-label={$t(\"button.download\")}\n                onclick={() => download(info.resultFile)}\n                disabled={downloading}\n                class:downloading\n            >\n                {#if !downloading}\n                    <IconDownload />\n                {:else}\n                    <IconLoader2 />\n                {/if}\n            </button>\n        {/if}\n\n        {#if !retrying}\n            {#if info.state === \"error\" && info?.canRetry}\n                <button\n                    class=\"button action-button\"\n                    aria-label={$t(\"button.retry\")}\n                    onclick={() => retry(info)}\n                >\n                    <IconReload />\n                </button>\n            {/if}\n            <button\n                class=\"button action-button\"\n                aria-label={$t(`button.${info.state === \"done\" ? \"delete\" : \"remove\"}`)}\n                onclick={() => removeItem(id)}\n                disabled={downloading}\n            >\n                <IconX />\n            </button>\n        {/if}\n    </div>\n</div>\n\n<style>\n    .processing-item,\n    .file-actions {\n        display: flex;\n        flex-direction: row;\n        justify-content: flex-start;\n        align-items: center;\n        position: relative;\n    }\n\n    .processing-item {\n        width: 100%;\n        padding: 8px 0;\n        gap: 8px;\n        border-bottom: 1.5px var(--button-elevated) solid;\n    }\n\n    .processing-type {\n        display: flex;\n    }\n\n    .processing-type :global(svg) {\n        width: 18px;\n        height: 18px;\n        stroke-width: 1.5px;\n    }\n\n    .processing-info {\n        display: flex;\n        flex-direction: column;\n        width: 100%;\n        font-size: 13px;\n        gap: 4px;\n        font-weight: 500;\n    }\n\n    .progress-holder {\n        display: flex;\n        flex-direction: row;\n        gap: 2px;\n    }\n\n    .file-title {\n        display: flex;\n        flex-direction: row;\n        gap: 4px;\n        line-break: anywhere;\n    }\n\n    .filename {\n        overflow: hidden;\n        white-space: pre;\n        text-overflow: ellipsis;\n    }\n\n    .file-status {\n        font-size: 12px;\n        color: var(--gray);\n        line-break: anywhere;\n        display: flex;\n        align-items: center;\n    }\n\n    .file-status.error:not(.retrying) {\n        color: var(--medium-red);\n    }\n\n    .file-status :global(svg) {\n        width: 16px;\n        height: 16px;\n        stroke-width: 2px;\n    }\n\n    .status-icon,\n    .status-spinner,\n    .status-text {\n        display: flex;\n    }\n\n    .status-text {\n        line-break: normal;\n    }\n\n    /*\n        margin is used instead of gap cuz queued state doesn't have an icon.\n        margin is applied only to the visible icon, so there's no awkward gap.\n    */\n    .status-icon :global(svg) {\n        margin-right: 6px;\n    }\n\n    :global([dir=\"rtl\"]) .status-icon :global(svg) {\n        margin-left: 6px;\n        margin-right: 0;\n    }\n\n    .file-actions {\n        gap: 4px;\n    }\n\n    @media (hover: hover) {\n        .file-actions {\n            position: absolute;\n            right: 0;\n            background-color: var(--button);\n            height: 90%;\n            padding-left: 18px;\n\n            transform: translateX(5px);\n\n            opacity: 0;\n            transition: opacity 0.15s, transform 0.15s;\n\n            mask-image: linear-gradient(\n                90deg,\n                rgba(255, 255, 255, 0) 0%,\n                rgba(0, 0, 0, 1) 20%\n            );\n        }\n\n        .queue-hidden .file-actions {\n            visibility: hidden;\n        }\n\n        :global([dir=\"rtl\"]) .file-actions {\n            left: 0;\n            right: unset;\n            padding-left: 0;\n            padding-right: 18px;\n\n            transform: translateX(-5px);\n\n            mask-image: linear-gradient(\n                -90deg,\n                rgba(255, 255, 255, 0) 0%,\n                rgba(0, 0, 0, 1) 20%\n            );\n        }\n\n        .processing-item:hover .file-actions,\n        .processing-item:focus-within .file-actions {\n            opacity: 1;\n            transform: none;\n        }\n    }\n\n    @media (hover: none) {\n        .processing-info {\n            overflow: hidden;\n            flex: 1;\n        }\n    }\n\n    .action-button {\n        padding: 8px;\n        height: auto;\n        box-shadow: none;\n        transition: opacity 0.2s;\n    }\n\n    .action-button :global(svg) {\n        width: 18px;\n        height: 18px;\n        stroke-width: 1.5px;\n    }\n\n    .action-button:disabled {\n        cursor: progress;\n        opacity: 0.5;\n    }\n\n    .status-spinner :global(svg),\n    .action-button.downloading :global(svg) {\n        animation: spinner 0.7s infinite linear;\n        will-change: transform;\n    }\n\n    .processing-item:first-child {\n        padding-top: 0;\n    }\n\n    .processing-item:last-child {\n        padding-bottom: 16px;\n        border: none;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/queue/ProcessingQueueStub.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n    import Meowbalt from \"$components/misc/Meowbalt.svelte\";\n</script>\n\n<div class=\"queue-stub\">\n    <Meowbalt emotion=\"think\" />\n    <span class=\"subtext stub-text\">\n        {$t(\"queue.stub\", {\n            value: $t(\"queue.stub\"),\n        })}\n    </span>\n</div>\n\n<style>\n    .queue-stub {\n        --base-padding: calc(var(--padding) * 1.5);\n        font-size: 13px;\n        font-weight: 500;\n        color: var(--gray);\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n        justify-content: center;\n        padding: var(--base-padding);\n        padding-bottom: calc(var(--base-padding) + 16px);\n        text-align: center;\n        gap: var(--padding);\n    }\n\n    .queue-stub :global(.meowbalt) {\n        height: 120px;\n    }\n\n    .stub-text {\n        padding: 0;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/queue/ProcessingStatus.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n    import IconArrowDown from \"@tabler/icons-svelte/IconArrowDown.svelte\";\n\n    type Props = {\n        indeterminate?: boolean;\n        progress?: number;\n        expandAction: () => void;\n    }\n\n    let {\n        indeterminate = false,\n        progress = $bindable(0),\n        expandAction\n    }: Props = $props();\n\n    let progressStroke = $derived(`${progress}, 100`);\n    const indeterminateStroke = \"15, 5\";\n\n    let ariaState = $derived(\n        progress > 0 && progress < 100\n        ? \"ongoing\"\n        : progress >= 100\n            ? \"completed\"\n            : \"default\"\n    )\n</script>\n\n<button\n    id=\"processing-status\"\n    onclick={expandAction}\n    class=\"button\"\n    class:completed={progress >= 100}\n    aria-label={$t(`a11y.queue.status.${ariaState}`)}\n>\n    <svg\n        id=\"progress-ring\"\n        class:indeterminate\n        class:progressive={progress > 0 && !indeterminate}\n    >\n        <circle\n            cx=\"19\"\n            cy=\"19\"\n            r=\"16\"\n            fill=\"none\"\n            stroke-dasharray={indeterminate\n                ? indeterminateStroke\n                : progressStroke}\n        />\n    </svg>\n    <div class=\"icon-holder\">\n        <IconArrowDown />\n    </div>\n</button>\n\n<style>\n    #processing-status {\n        pointer-events: all;\n        padding: 7px;\n        border-radius: 30px;\n\n        filter: drop-shadow(0 0 3px var(--button-elevated-hover));\n\n        transition:\n            background-color 0.2s,\n            transform 0.2s;\n\n        will-change: transform, background-color;\n    }\n\n    #processing-status:focus-visible {\n        outline: 2px solid var(--secondary);\n        outline-offset: 2px;\n    }\n\n    #processing-status:active {\n        transform: scale(0.9);\n    }\n\n    #processing-status.completed {\n        box-shadow: 0 0 0 2px var(--blue) inset;\n    }\n\n    :global([data-theme=\"light\"]) #processing-status.completed {\n        background-color: #e0eeff;\n    }\n\n    :global([data-theme=\"dark\"]) #processing-status.completed {\n        background-color: #1f3249;\n    }\n\n    .icon-holder {\n        display: flex;\n        background-color: var(--button-elevated-hover);\n        padding: 2px;\n        border-radius: 20px;\n        transition: background-color 0.2s;\n    }\n\n    .icon-holder :global(svg) {\n        height: 21px;\n        width: 21px;\n        stroke: var(--secondary);\n        stroke-width: 1.5px;\n        transition: stroke 0.2s;\n    }\n\n    .completed .icon-holder {\n        background-color: var(--blue);\n    }\n\n    .completed .icon-holder :global(svg) {\n        stroke: white;\n    }\n\n    #progress-ring {\n        position: absolute;\n        transform: rotate(-90deg);\n        width: 38px;\n        height: 38px;\n        opacity: 0;\n        transition: opacity 0.2s;\n    }\n\n    #progress-ring circle {\n        stroke: var(--blue);\n        stroke-width: 4;\n        stroke-dashoffset: 0;\n    }\n\n    #progress-ring.progressive circle {\n        transition: stroke-dasharray 0.2s;\n    }\n\n    #progress-ring.progressive,\n    #progress-ring.indeterminate {\n        opacity: 1;\n    }\n\n    #progress-ring.indeterminate {\n        animation: spinner 3s linear infinite;\n        will-change: transform;\n    }\n\n    #progress-ring.indeterminate circle {\n        transition: none;\n    }\n\n    .completed #progress-ring {\n        opacity: 0;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/queue/ProgressBar.svelte",
    "content": "<script lang=\"ts\">\n    import Skeleton from \"$components/misc/Skeleton.svelte\";\n    import type { CobaltQueueItemRunning, UUID } from \"$lib/types/queue\";\n\n    type Props = {\n        percentage?: number;\n        workerId: UUID;\n        pipelineResults: CobaltQueueItemRunning['pipelineResults'];\n    }\n\n    let { percentage = 0, workerId, pipelineResults }: Props = $props();\n</script>\n\n<div class=\"file-progress\">\n    {#if percentage}\n        <div\n            class=\"progress\"\n            style=\"width: {Math.min(100, percentage)}%\"\n        ></div>\n    {:else if pipelineResults[workerId]}\n        <div\n            class=\"progress\"\n            style=\"width: 100%\"\n        ></div>\n    {:else}\n        <Skeleton\n            height=\"6px\"\n            width=\"100%\"\n            class=\"elevated indeterminate-progress\"\n        />\n    {/if}\n</div>\n\n<style>\n    .file-progress {\n        width: 100%;\n        background-color: var(--button-elevated);\n    }\n\n    .file-progress,\n    .file-progress .progress {\n        height: 6px;\n        border-radius: 10px;\n        transition: width 0.1s;\n    }\n\n    .file-progress :global(.indeterminate-progress) {\n        display: block;\n    }\n\n    .file-progress .progress {\n        background-color: var(--blue);\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/save/CaptchaTooltip.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n\n    type Props = {\n        visible: boolean;\n    };\n\n    let { visible }: Props = $props();\n</script>\n\n<div class=\"tooltip-holder\" class:visible aria-hidden=\"true\">\n    <div class=\"tooltip-body\">\n        <div class=\"tooltip-content subtext\">\n            {$t(\"save.tooltip.captcha\")}\n        </div>\n        <div class=\"tooltip-pointer border\"></div>\n        <div class=\"tooltip-pointer\"></div>\n    </div>\n</div>\n\n<style>\n    .tooltip-holder {\n        position: absolute;\n        bottom: calc(100% + 10px);\n\n        opacity: 0;\n        transform: scale(0.5) translateX(10px) translateY(15px);\n        transform-origin: bottom left;\n\n        transition:\n            transform 0.2s cubic-bezier(0.53, 0.05, 0.23, 1.15),\n            opacity 0.2s cubic-bezier(0.53, 0.05, 0.23, 0.99);\n\n        will-change: transform, opacity;\n    }\n\n    .tooltip-holder.visible {\n        opacity: 1;\n        transform: none;\n    }\n\n    .tooltip-body {\n        max-width: 190px;\n        position: relative;\n\n        pointer-events: none;\n\n        padding: 8px 14px;\n        border-radius: 11px;\n\n        background: var(--button);\n        box-shadow: var(--button-box-shadow);\n\n        filter: drop-shadow(0 0 8px var(--popover-glow));\n    }\n\n    .tooltip-content {\n        padding: 0;\n    }\n\n    .tooltip-pointer {\n        position: absolute;\n        top: calc(100% - 7px);\n        left: 14px;\n        transform: rotate(45deg);\n        background: var(--button);\n        z-index: 2;\n        height: 10px;\n        width: 10px;\n    }\n\n    .tooltip-pointer.border {\n        box-shadow: var(--button-box-shadow);\n        z-index: 1;\n        margin-top: 2px;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/save/Omnibox.svelte",
    "content": "<script lang=\"ts\">\n    import env, { officialApiURL } from \"$lib/env\";\n\n    import { tick } from \"svelte\";\n    import { page } from \"$app/state\";\n    import { goto } from \"$app/navigation\";\n    import { browser } from \"$app/environment\";\n\n    import { t } from \"$lib/i18n/translations\";\n\n    import dialogs from \"$lib/state/dialogs\";\n    import { link } from \"$lib/state/omnibox\";\n    import { hapticSwitch } from \"$lib/haptics\";\n    import { updateSetting } from \"$lib/state/settings\";\n    import { savingHandler } from \"$lib/api/saving-handler\";\n    import { pasteLinkFromClipboard } from \"$lib/clipboard\";\n    import { turnstileEnabled, turnstileSolved } from \"$lib/state/turnstile\";\n\n    import type { Optional } from \"$lib/types/generic\";\n    import type { DownloadModeOption } from \"$lib/types/settings\";\n\n    import ClearButton from \"$components/save/buttons/ClearButton.svelte\";\n    import DownloadButton from \"$components/save/buttons/DownloadButton.svelte\";\n\n    import Switcher from \"$components/buttons/Switcher.svelte\";\n    import OmniboxIcon from \"$components/save/OmniboxIcon.svelte\";\n    import ActionButton from \"$components/buttons/ActionButton.svelte\";\n    import CaptchaTooltip from \"$components/save/CaptchaTooltip.svelte\";\n    import SettingsButton from \"$components/buttons/SettingsButton.svelte\";\n\n    import IconMute from \"$components/icons/Mute.svelte\";\n    import IconMusic from \"$components/icons/Music.svelte\";\n    import IconSparkles from \"$components/icons/Sparkles.svelte\";\n    import IconClipboard from \"$components/icons/Clipboard.svelte\";\n\n    let linkInput: Optional<HTMLInputElement>;\n\n    const validLink = (url: string) => {\n        try {\n            return /^https?\\:/i.test(new URL(url).protocol);\n        } catch {}\n    };\n\n    let isFocused = $state(false);\n    let isDisabled = $state(false);\n    let isLoading = $state(false);\n\n    let isHovered = $state(false);\n\n    let isBotCheckOngoing = $derived($turnstileEnabled && !$turnstileSolved);\n\n    let linkPrefill = $derived(\n        page.url.hash.replace(\"#\", \"\")\n        || (browser ? page.url.searchParams.get(\"u\") : \"\")\n        || \"\"\n    );\n\n    let downloadable = $derived(validLink($link));\n    let clearVisible = $derived($link && !isLoading);\n\n    $effect (() => {\n        if (linkPrefill) {\n            // prefilled link may be uri encoded\n            linkPrefill = decodeURIComponent(linkPrefill);\n\n            if (validLink(linkPrefill)) {\n                $link = linkPrefill;\n            }\n\n            // clear hash and query to prevent bookmarking unwanted links\n            if (browser) goto(\"/\", { replaceState: true });\n\n            // clear link prefill to avoid extra effects\n            linkPrefill = \"\";\n\n            savingHandler({ url: $link });\n        }\n    });\n\n    const pasteClipboard = async () => {\n        if ($dialogs.length > 0 || isDisabled || isLoading) {\n            return;\n        }\n\n        hapticSwitch();\n\n        const pastedData = await pasteLinkFromClipboard();\n        if (!pastedData) return;\n\n        const linkMatch = pastedData.match(/https?\\:\\/\\/[^\\s]+/g);\n\n        if (linkMatch) {\n            $link = linkMatch[0].split('，')[0];\n\n            await tick(); // wait for button to render\n            savingHandler({ url: $link });\n        }\n    };\n\n    const changeDownloadMode = (mode: DownloadModeOption) => {\n        updateSetting({ save: { downloadMode: mode } });\n    };\n\n    const handleKeydown = (e: KeyboardEvent) => {\n        if (!linkInput || $dialogs.length > 0 || isDisabled || isLoading) {\n            return;\n        }\n\n        if (e.metaKey || e.ctrlKey || e.key === \"/\") {\n            linkInput.focus();\n        }\n\n        if (e.key === \"Enter\" && validLink($link) && isFocused) {\n            savingHandler({ url: $link });\n        }\n\n        if ([\"Escape\", \"Clear\"].includes(e.key) && isFocused) {\n            $link = \"\";\n        }\n\n        if (e.target === linkInput) {\n            return;\n        }\n\n        switch (e.key) {\n            case \"D\":\n                pasteClipboard();\n                break;\n            case \"J\":\n                changeDownloadMode(\"auto\");\n                break;\n            case \"K\":\n                changeDownloadMode(\"audio\");\n                break;\n            case \"L\":\n                changeDownloadMode(\"mute\");\n                break;\n            default:\n                break;\n        }\n    };\n</script>\n\n<svelte:window onkeydown={handleKeydown} />\n\n<!--\n    if you want to remove the community instance label,\n    refer to the license first https://github.com/imputnet/cobalt/tree/main/web#license\n-->\n{#if env.DEFAULT_API !== officialApiURL}\n    <div id=\"instance-label\">\n        {$t(\"save.label.community_instance\")}\n    </div>\n{/if}\n\n<div id=\"omnibox\">\n    {#if $turnstileEnabled}\n        <CaptchaTooltip\n            visible={isBotCheckOngoing && (isHovered || isFocused)}\n        />\n    {/if}\n\n    <div\n        id=\"input-container\"\n        class:focused={isFocused}\n        class:downloadable\n        class:clear-visible={clearVisible}\n    >\n        <OmniboxIcon loading={isLoading || isBotCheckOngoing} />\n\n        <input\n            id=\"link-area\"\n            bind:value={$link}\n            bind:this={linkInput}\n            oninput={() => (isFocused = true)}\n            onfocus={() => (isFocused = true)}\n            onblur={() => (isFocused = false)}\n            onmouseover={() => (isHovered = true)}\n            onmouseleave={() => (isHovered = false)}\n            spellcheck=\"false\"\n            autocomplete=\"off\"\n            autocapitalize=\"off\"\n            maxlength=\"512\"\n            placeholder={$t(\"save.input.placeholder\")}\n            aria-label={isBotCheckOngoing\n                ? $t(\"a11y.save.link_area.turnstile\")\n                : $t(\"a11y.save.link_area\")}\n            data-form-type=\"other\"\n            disabled={isDisabled}\n        />\n\n        <ClearButton click={() => ($link = \"\")} />\n        <DownloadButton\n            url={$link}\n            bind:disabled={isDisabled}\n            bind:loading={isLoading}\n        />\n    </div>\n\n    <div id=\"action-container\">\n        <Switcher>\n            <SettingsButton\n                settingContext=\"save\"\n                settingId=\"downloadMode\"\n                settingValue=\"auto\"\n            >\n                <IconSparkles />\n                {$t(\"save.auto\")}\n            </SettingsButton>\n            <SettingsButton\n                settingContext=\"save\"\n                settingId=\"downloadMode\"\n                settingValue=\"audio\"\n            >\n                <IconMusic />\n                {$t(\"save.audio\")}\n            </SettingsButton>\n            <SettingsButton\n                settingContext=\"save\"\n                settingId=\"downloadMode\"\n                settingValue=\"mute\"\n            >\n                <IconMute />\n                {$t(\"save.mute\")}\n            </SettingsButton>\n        </Switcher>\n\n        <ActionButton id=\"paste\" click={pasteClipboard}>\n            <IconClipboard />\n            <span id=\"paste-desktop-text\">{$t(\"save.paste\")}</span>\n            <span id=\"paste-mobile-text\">{$t(\"save.paste.long\")}</span>\n        </ActionButton>\n    </div>\n</div>\n\n<style>\n    #omnibox {\n        display: flex;\n        flex-direction: column;\n        max-width: 640px;\n        width: 100%;\n        gap: 6px;\n        position: relative;\n    }\n\n    #input-container {\n        --input-padding: 10px;\n        display: flex;\n        box-shadow: 0 0 0 1.5px var(--input-border) inset;\n        /* webkit can't render the 1.5px box shadow properly,\n           so we duplicate the border as outline to fix it visually */\n        outline: 1.5px solid var(--input-border);\n        outline-offset: -1.5px;\n        border-radius: var(--border-radius);\n        align-items: center;\n        gap: var(--input-padding);\n        font-size: 14px;\n        flex: 1;\n    }\n\n    #input-container:not(.clear-visible) :global(#clear-button) {\n        display: none;\n    }\n\n    #input-container:not(.downloadable) :global(#download-button) {\n        display: none;\n    }\n\n    #input-container.clear-visible {\n        padding-right: var(--input-padding);\n    }\n\n    :global([dir=\"rtl\"]) #input-container.clear-visible {\n        padding-right: unset;\n        padding-left: var(--input-padding);\n    }\n\n    #input-container.downloadable {\n        padding-right: 0;\n    }\n\n    #input-container.downloadable:dir(rtl) {\n        padding-left: 0;\n    }\n\n    #input-container.focused {\n        box-shadow: none;\n        outline: var(--secondary) 2px solid;\n        outline-offset: -1px;\n    }\n\n    #input-container.focused :global(#input-icons svg) {\n        stroke: var(--secondary);\n    }\n\n    #input-container.downloadable :global(#input-icons svg) {\n        stroke: var(--secondary);\n    }\n\n    #link-area {\n        display: flex;\n        width: 100%;\n        margin: 0;\n        padding: var(--input-padding) 0;\n        padding-left: calc(var(--input-padding) + 28px);\n        height: 18px;\n\n        align-items: center;\n\n        border: none;\n        outline: none;\n        background-color: transparent;\n        color: var(--secondary);\n\n        -webkit-tap-highlight-color: transparent;\n        flex: 1;\n\n        font-weight: 500;\n\n        /* workaround for safari */\n        font-size: inherit;\n\n        /* prevents input from poking outside of rounded corners */\n        border-radius: var(--border-radius);\n    }\n\n    :global([dir=\"rtl\"]) #link-area {\n        padding-left: unset;\n        padding-right: calc(var(--input-padding) + 28px);\n    }\n\n    #link-area::placeholder {\n        color: var(--gray);\n        /* fix for firefox */\n        opacity: 1;\n    }\n\n    /* fix for safari */\n    input:disabled {\n        opacity: 1;\n    }\n\n    #action-container {\n        display: flex;\n        flex-direction: row;\n    }\n\n    #action-container {\n        justify-content: space-between;\n    }\n\n    #paste-mobile-text {\n        display: none;\n    }\n\n    #instance-label {\n        font-size: 13px;\n        color: var(--gray);\n        font-weight: 500;\n    }\n\n    @media screen and (max-width: 440px) {\n        #action-container {\n            flex-direction: column;\n            gap: 5px;\n        }\n\n        #action-container :global(.button) {\n            width: 100%;\n        }\n\n        #paste-mobile-text {\n            display: block;\n        }\n\n        #paste-desktop-text {\n            display: none;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/save/OmniboxIcon.svelte",
    "content": "<script lang=\"ts\">\n    import IconLink from \"@tabler/icons-svelte/IconLink.svelte\";\n    import IconLoader2 from \"@tabler/icons-svelte/IconLoader2.svelte\";\n\n    type Props = {\n        loading: boolean;\n    };\n\n    let { loading }: Props = $props();\n\n    let animated = $state(loading);\n\n    /*\n        initial spinner state is equal to loading state,\n        just so it's animated on init (or not).\n        on transition start, it overrides the value\n        to start spinning (to prevent zooming in with no spinning).\n\n        then, on transition end, when the spinner is hidden,\n        and if loading state is false, the class is removed\n        and the spinner doesn't spin in background while being invisible.\n\n        if loading state is true, then it will just stay spinning\n        (aka when it's visible and should be spinning).\n\n        the spin on transition start is needed for the whirlpool effect\n        of the link icon being sucked into the spinner.\n\n        this may be unnecessarily complicated but i think it looks neat.\n    */\n</script>\n\n<div id=\"input-icons\" class:loading>\n    <div\n        class=\"input-icon spinner-icon\"\n        class:animated\n        ontransitionstart={() => (animated = true)}\n        ontransitionend={() => (animated = loading)}\n    >\n        <IconLoader2 />\n    </div>\n    <div class=\"input-icon link-icon\">\n        <IconLink />\n    </div>\n</div>\n\n<style>\n    #input-icons,\n    #input-icons :global(svg),\n    .input-icon {\n        width: 18px;\n        height: 18px;\n    }\n\n    #input-icons {\n        display: flex;\n        position: absolute;\n        margin-left: var(--input-padding);\n        pointer-events: none;\n    }\n\n    :global([dir=\"rtl\"]) #input-icons {\n        margin-left: unset;\n        margin-right: var(--input-padding);\n    }\n\n    #input-icons :global(svg) {\n        stroke: var(--gray);\n        stroke-width: 2px;\n        will-change: transform;\n    }\n\n    .input-icon {\n        position: absolute;\n        transition:\n            transform 0.25s,\n            opacity 0.25s;\n    }\n\n    .link-icon {\n        transform: none;\n        opacity: 1;\n    }\n\n    .spinner-icon {\n        transform: scale(0.4);\n        opacity: 0;\n    }\n\n    .spinner-icon.animated :global(svg) {\n        animation: spinner 0.7s infinite linear;\n    }\n\n    .loading .link-icon :global(svg) {\n        animation: spinner 0.7s linear;\n    }\n\n    .loading .link-icon {\n        transform: scale(0.4);\n        opacity: 0;\n    }\n\n    .loading .spinner-icon {\n        transform: none;\n        opacity: 1;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/save/SupportedServices.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n    import cachedInfo from \"$lib/state/server-info\";\n    import { getServerInfo } from \"$lib/api/server-info\";\n\n    import Skeleton from \"$components/misc/Skeleton.svelte\";\n    import IconPlus from \"@tabler/icons-svelte/IconPlus.svelte\";\n    import PopoverContainer from \"$components/misc/PopoverContainer.svelte\";\n\n    let services: string[] = [];\n\n    $: expanded = false;\n\n    let servicesContainer: HTMLDivElement;\n    $: loaded = false;\n\n    const loadInfo = async () => {\n        await getServerInfo();\n\n        if ($cachedInfo) {\n            loaded = true;\n            services = $cachedInfo.info.cobalt.services;\n        }\n    };\n\n    const popoverAction = async () => {\n        expanded = !expanded;\n        if (expanded && services.length === 0) {\n            await loadInfo();\n        }\n        if (expanded) {\n            servicesContainer.focus();\n        }\n    };\n</script>\n\n<div id=\"supported-services\" class:expanded>\n    <button\n        id=\"services-button\"\n        class=\"button\"\n        on:click={popoverAction}\n        aria-label={$t(`save.services.title_${expanded ? \"hide\" : \"show\"}`)}\n    >\n        <div class=\"expand-icon\">\n            <IconPlus />\n        </div>\n        <span class=\"title\">{$t(\"save.services.title\")}</span>\n    </button>\n\n    <PopoverContainer id=\"services-popover\" {expanded}>\n        <div\n            id=\"services-container\"\n            bind:this={servicesContainer}\n            tabindex=\"-1\"\n        >\n            {#if loaded}\n                {#each services as service}\n                    <div class=\"service-item\">{service}</div>\n                {/each}\n            {:else}\n                {#each { length: 17 } as _}\n                    <Skeleton\n                        class=\"elevated\"\n                        width={Math.random() * 44 + 50 + \"px\"}\n                        height=\"24.5px\"\n                    />\n                {/each}\n            {/if}\n        </div>\n        <div id=\"services-disclaimer\" class=\"subtext\">\n            {$t(\"save.services.disclaimer\")}\n        </div>\n    </PopoverContainer>\n</div>\n\n<style>\n    #supported-services {\n        display: flex;\n        position: relative;\n        max-width: 400px;\n        flex-direction: column;\n        align-items: center;\n        height: 35px;\n    }\n\n    #services-button {\n        gap: 9px;\n        padding: 7px 13px 7px 10px;\n        justify-content: flex-start;\n        border-radius: 18px;\n        display: flex;\n        flex-direction: row;\n        font-size: 13px;\n        font-weight: 500;\n        background: none;\n        transition:\n            background 0.2s,\n            box-shadow 0.1s;\n    }\n\n    #services-button:not(:active) {\n        box-shadow: none;\n    }\n\n    .expand-icon {\n        height: 22px;\n        width: 22px;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        border-radius: 18px;\n        background: var(--button-elevated);\n        padding: 0;\n        box-shadow: none;\n        transition:\n            background 0.2s,\n            transform 0.2s;\n    }\n\n    #services-button:active {\n        background: var(--button-hover-transparent);\n    }\n\n    @media (hover: hover) {\n        #services-button:hover {\n            background: var(--button-hover-transparent);\n        }\n\n        #services-button:active {\n            background: var(--button-press-transparent);\n        }\n\n        #services-button:hover .expand-icon {\n            background: var(--button-elevated-hover);\n        }\n    }\n\n    @media (hover: none) {\n        #services-button:active {\n            box-shadow: none;\n        }\n    }\n\n    #services-button:active .expand-icon {\n        background: var(--button-elevated-press);\n    }\n\n    .expand-icon :global(svg) {\n        height: 18px;\n        width: 18px;\n        stroke-width: 2px;\n        color: var(--secondary);\n        will-change: transform;\n    }\n\n    .expanded .expand-icon {\n        transform: rotate(45deg);\n    }\n\n    #services-container {\n        display: flex;\n        flex-wrap: wrap;\n        flex-direction: row;\n        gap: 3px;\n    }\n\n    .service-item {\n        display: flex;\n        padding: 4px 8px;\n        border-radius: calc(var(--border-radius) / 2);\n        background: var(--button-elevated);\n        font-size: 12.5px;\n        font-weight: 500;\n    }\n\n    #services-disclaimer {\n        padding: 0;\n        user-select: none;\n        -webkit-user-select: none;\n    }\n\n    .expanded #services-disclaimer {\n        padding: 0;\n        user-select: text;\n        -webkit-user-select: text;\n    }\n\n    @media screen and (max-width: 535px) {\n        .expand-icon {\n            height: 21px;\n            width: 21px;\n        }\n\n        .expand-icon :global(svg) {\n            height: 16px;\n            width: 16px;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/save/buttons/ClearButton.svelte",
    "content": "<script>\n    import { t } from \"$lib/i18n/translations\";\n    import IconX from \"@tabler/icons-svelte/IconX.svelte\";\n\n    export let click;\n</script>\n\n<button\n    id=\"clear-button\"\n    class=\"button\"\n    on:click={click}\n    aria-label={$t(\"a11y.save.clear_input\")}\n>\n    <IconX />\n</button>\n\n<style>\n    #clear-button {\n        padding: 3px;\n        border-radius: 100%;\n    }\n\n    #clear-button :global(svg) {\n        width: 16px;\n        height: 16px;\n        stroke-width: 2px;\n        stroke: var(--secondary);\n        will-change: transform;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/save/buttons/DownloadButton.svelte",
    "content": "<script lang=\"ts\">\n    import { onDestroy } from \"svelte\";\n    import { t } from \"$lib/i18n/translations\";\n    import { hapticSwitch } from \"$lib/haptics\";\n    import { savingHandler } from \"$lib/api/saving-handler\";\n    import { downloadButtonState } from \"$lib/state/omnibox\";\n\n    import type { CobaltDownloadButtonState } from \"$lib/types/omnibox\";\n\n    export let url: string;\n    export let disabled = false;\n    export let loading = false;\n\n    $: buttonText = \">>\";\n    $: buttonAltText = $t(\"a11y.save.download\");\n\n    type DownloadButtonState = \"idle\" | \"think\" | \"check\" | \"done\" | \"error\";\n\n    const unsubscribe = downloadButtonState.subscribe(\n        (state: CobaltDownloadButtonState) => {\n            disabled = state !== \"idle\";\n            loading = state === \"think\" || state === \"check\";\n\n            buttonText = {\n                idle: \">>\",\n                think: \"...\",\n                check: \"..?\",\n                done: \">>>\",\n                error: \"!!\",\n            }[state];\n\n            buttonAltText = $t(\n                {\n                    idle: \"a11y.save.download\",\n                    think: \"a11y.save.download.think\",\n                    check: \"a11y.save.download.check\",\n                    done: \"a11y.save.download.done\",\n                    error: \"a11y.save.download.error\",\n                }[state]\n            );\n\n            // states that don't wait for anything, and thus can\n            // transition back to idle after some period of time.\n            const final: DownloadButtonState[] = [\"done\", \"error\"];\n            if (final.includes(state)) {\n                setTimeout(() => downloadButtonState.set(\"idle\"), 1500);\n            }\n        }\n    );\n\n    onDestroy(() => unsubscribe());\n</script>\n\n<button\n    id=\"download-button\"\n    {disabled}\n    on:click={() => {\n        hapticSwitch();\n        savingHandler({ url });\n    }}\n    aria-label={buttonAltText}\n>\n    <span id=\"download-state\">{buttonText}</span>\n</button>\n\n<style>\n    #download-button {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n\n        height: 100%;\n        min-width: 48px;\n        width: 48px;\n\n        border-radius: 0;\n\n        /* visually align the button, +1.5px because of inset box-shadow on parent */\n        padding: 0 13.5px 0 12px;\n\n        background: none;\n        box-shadow: none;\n        transform: none;\n\n        border-left: 1.5px var(--input-border) solid;\n        border-top-right-radius: var(--border-radius);\n        border-bottom-right-radius: var(--border-radius);\n    }\n\n    #download-button:dir(rtl) {\n        border-left: 0;\n        border-top-right-radius: 0;\n        border-bottom-right-radius: 0;\n\n        border-right: 1.5px var(--input-border) solid;\n        border-top-left-radius: var(--border-radius);\n        border-bottom-left-radius: var(--border-radius);\n\n        direction: ltr;\n        padding: 0 12px 0 15px;\n    }\n\n    #download-state {\n        font-size: 24px;\n        font-family: \"Noto Sans Mono\", \"IBM Plex Mono\", monospace;\n        font-weight: 400;\n\n        text-align: center;\n        text-indent: -5px;\n        letter-spacing: -5.3px;\n\n        margin-bottom: 2px;\n    }\n\n    #download-button:disabled {\n        cursor: unset;\n        color: var(--gray);\n    }\n\n    :global(#input-container.focused) #download-button {\n        border-left: 2px var(--secondary) solid;\n    }\n\n    :global(#input-container.focused) #download-button:dir(rtl) {\n        border-left: 0;\n        border-right: 2px var(--secondary) solid;\n    }\n\n    @media (hover: hover) {\n        #download-button:hover:not(:disabled) {\n            background: var(--button-hover-transparent);\n        }\n    }\n\n    #download-button:active:not(:disabled) {\n        background: var(--button-press-transparent);\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/settings/ClearStorageButton.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n    import { createDialog } from \"$lib/state/dialogs\";\n    import { clearQueue } from \"$lib/state/task-manager/queue\";\n    import { clearFileStorage } from \"$lib/storage/opfs\";\n\n    import IconFileShredder from \"@tabler/icons-svelte/IconFileShredder.svelte\";\n    import DataSettingsButton from \"$components/settings/DataSettingsButton.svelte\";\n\n    const clearDialog = () => {\n        createDialog({\n            id: \"wipe-confirm\",\n            type: \"small\",\n            icon: \"warn-red\",\n            title: $t(\"dialog.clear_cache.title\"),\n            bodyText: $t(\"dialog.clear_cache.body\"),\n            buttons: [\n                {\n                    text: $t(\"button.cancel\"),\n                    main: false,\n                    action: () => {},\n                },\n                {\n                    text: $t(\"button.clear\"),\n                    color: \"red\",\n                    main: true,\n                    timeout: 2000,\n                    action: async () => {\n                        clearQueue();\n                        await clearFileStorage();\n                        if ('caches' in window) {\n                            const keys = await caches.keys();\n                            await Promise.all(keys.map(key => caches.delete(key)));\n                        }\n                    },\n                },\n            ],\n        });\n    };\n</script>\n\n<DataSettingsButton id=\"clear-cache\" click={clearDialog} danger>\n    <IconFileShredder />\n    {$t(\"button.clear_cache\")}\n</DataSettingsButton>\n"
  },
  {
    "path": "web/src/components/settings/DataSettingsButton.svelte",
    "content": "<script lang=\"ts\">\n    export let id: string;\n    export let click: () => void;\n    export let danger = false;\n</script>\n\n<button {id} class=\"button data-button\" class:danger on:click={click}>\n    <slot></slot>\n</button>\n\n<style>\n    .data-button {\n        padding: 8px 14px;\n        width: max-content;\n        text-align: start;\n    }\n\n    .data-button :global(svg) {\n        stroke-width: 1.8px;\n        height: 21px;\n        width: 21px;\n    }\n\n    .data-button.danger {\n        background-color: var(--red);\n        color: var(--white);\n    }\n\n    .data-button.danger:hover {\n        background-color: var(--dark-red);\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/settings/FilenamePreview.svelte",
    "content": "<script lang=\"ts\">\n    import settings from \"$lib/state/settings\";\n    import { t } from \"$lib/i18n/translations\";\n\n    import IconMovie from \"@tabler/icons-svelte/IconMovie.svelte\";\n    import IconMusic from \"@tabler/icons-svelte/IconMusic.svelte\";\n\n    let videoFilePreview: string;\n    let audioFilePreview: string;\n\n    const videoTitle = $t(\"settings.metadata.filename.preview.video\");\n    const audioTitle = $t(\"settings.metadata.filename.preview.audio\");\n\n    const infoBase = [\"youtube\", \"dQw4w9WgXcQ\"];\n\n    const fullResolution = {\n        \"2160\": \"3840x2160\",\n        \"1440\": \"2560x1440\",\n        \"1080\": \"1920x1080\",\n        \"720\": \"1280x720\",\n        \"480\": \"854x480\",\n        \"360\": \"640x360\",\n        \"240\": \"426x240\",\n        \"144\": \"256x144\",\n    };\n\n    let { downloadMode, youtubeVideoCodec, audioFormat, videoQuality } =\n        $settings.save;\n\n    let youtubeVideoExt = youtubeVideoCodec === \"vp9\" ? \"webm\" : \"mp4\";\n\n    audioFormat = audioFormat !== \"best\" ? audioFormat : \"opus\";\n    videoQuality = videoQuality !== \"max\" ? videoQuality : \"2160\";\n\n    if (youtubeVideoCodec === \"h264\" && Number(videoQuality) > 1080) {\n        videoQuality = \"1080\";\n    }\n\n    let classicTags = infoBase.concat([\n        fullResolution[videoQuality],\n        youtubeVideoCodec,\n    ]);\n\n    let basicTags = [`${videoQuality}p`, youtubeVideoCodec];\n\n    if (downloadMode === \"mute\") {\n        classicTags.push(\"mute\");\n        basicTags.push(\"mute\");\n    }\n\n    $: switch ($settings.save.filenameStyle) {\n        case \"classic\":\n            videoFilePreview = classicTags.join(\"_\");\n            audioFilePreview = \"youtube_dQw4w9WgXcQ_audio\";\n            break;\n        case \"basic\":\n            videoFilePreview = `${videoTitle} (${basicTags.join(\", \")})`;\n            audioFilePreview = audioTitle;\n            break;\n        case \"pretty\":\n            videoFilePreview = `${videoTitle} (${[...basicTags, infoBase[0]].join(\", \")})`;\n            audioFilePreview = `${audioTitle} (${infoBase[0]})`;\n            break;\n        case \"nerdy\":\n            videoFilePreview = `${videoTitle} (${basicTags.concat(infoBase).join(\", \")})`;\n            audioFilePreview = `${audioTitle} (${infoBase.join(\", \")})`;\n            break;\n    }\n</script>\n\n<div id=\"filename-preview\">\n    <div id=\"filename-preview-video\" class=\"filename-preview-item\">\n        <div class=\"item-icon\">\n            <IconMovie />\n        </div>\n        <div class=\"item-text\">\n            <div class=\"preview\">{`${videoFilePreview}.${youtubeVideoExt}`}</div>\n            <div class=\"subtext description\">{$t(\"settings.filename.preview_desc.video\")}</div>\n        </div>\n    </div>\n    <div id=\"filename-preview-audio\" class=\"filename-preview-item\">\n        <div class=\"item-icon\">\n            <IconMusic />\n        </div>\n        <div class=\"item-text\">\n            <div class=\"preview\">{`${audioFilePreview}.${audioFormat}`}</div>\n            <div class=\"subtext description\">{$t(\"settings.filename.preview_desc.audio\")}</div>\n        </div>\n    </div>\n</div>\n\n<style>\n    #filename-preview {\n        font-weight: 500;\n\n        display: flex;\n        flex-direction: column;\n\n        background: var(--button);\n        box-shadow: var(--button-box-shadow);\n        border-radius: var(--border-radius);\n    }\n\n    .filename-preview-item {\n        display: flex;\n        flex-direction: row;\n        align-items: center;\n        justify-content: flex-start;\n        gap: 9px;\n        padding: 7px var(--padding);\n    }\n\n    .filename-preview-item:first-child {\n        border-bottom: 1px var(--button-stroke) solid;\n    }\n\n    .filename-preview-item:last-child {\n        padding-top: 6px;\n    }\n\n    .item-icon {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        padding: 4px;\n        border-radius: 6px;\n        background-color: var(--gray);\n    }\n\n    .item-icon :global(svg) {\n        stroke: var(--white);\n        stroke-width: 1.5px;\n        height: 22px;\n        width: 22px;\n        will-change: transform;\n    }\n\n    .item-text {\n        display: flex;\n        flex-direction: column;\n        font-size: 14px;\n        overflow-wrap: anywhere;\n    }\n\n    .item-text .preview {\n        line-height: 1.3;\n    }\n\n    .item-text .description {\n        padding: 0;\n        line-height: 1.3;\n    }\n\n    @media screen and (max-width: 750px) {\n        .item-text {\n            font-size: 13px;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/settings/ManageSettings.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n    import { downloadFile } from \"$lib/download\";\n    import { createDialog } from \"$lib/state/dialogs\";\n    import { validateSettings } from \"$lib/settings/validate\";\n    import { storedSettings, updateSetting, loadFromString } from \"$lib/state/settings\";\n\n    import DataSettingsButton from \"$components/settings/DataSettingsButton.svelte\";\n    import ResetSettingsButton from \"$components/settings/ResetSettingsButton.svelte\";\n\n    import IconFileExport from \"@tabler/icons-svelte/IconFileExport.svelte\";\n    import IconFileImport from \"@tabler/icons-svelte/IconFileImport.svelte\";\n\n    const updateSettings = (reader: FileReader) => {\n        try {\n            const data = reader.result?.toString();\n            if (!data) throw $t(\"error.import.no_data\");\n\n            const loadedSettings = loadFromString(data);\n            if (!validateSettings(loadedSettings))\n                throw $t(\"error.import.invalid\");\n\n            createDialog({\n                id: \"import-confirm\",\n                type: \"small\",\n                icon: \"warn-red\",\n                title: $t(\"dialog.safety.title\"),\n                bodyText: $t(\"dialog.import.body\"),\n                buttons: [\n                    {\n                        text: $t(\"button.cancel\"),\n                        main: false,\n                        action: () => {},\n                    },\n                    {\n                        text: $t(\"button.import\"),\n                        color: \"red\",\n                        main: true,\n                        timeout: 5000,\n                        action: () => updateSetting(loadFromString(data)),\n                    },\n                ],\n            });\n        } catch (e) {\n            let message = $t(\"error.import.no_data\");\n\n            if (e instanceof Error) {\n                console.error(\"settings import error:\", e);\n                message = $t(\"error.import.unknown\", { value: e.message });\n            } else if (typeof e === \"string\") {\n                message = e;\n            }\n\n            createDialog({\n                id: \"settings-import-error\",\n                type: \"small\",\n                meowbalt: \"error\",\n                bodyText: message,\n                buttons: [\n                    {\n                        text: $t(\"button.gotit\"),\n                        main: true,\n                        action: () => {},\n                    },\n                ],\n            });\n        }\n    };\n\n    const importSettings = () => {\n        const pseudoinput = document.createElement(\"input\");\n        pseudoinput.type = \"file\";\n        pseudoinput.accept = \".json\";\n        pseudoinput.onchange = (e: Event) => {\n            const target = e.target as HTMLInputElement;\n            const reader = new FileReader();\n\n            reader.onload = () => updateSettings(reader);\n\n            if (target.files?.length === 1) {\n                reader.readAsText(target.files[0]);\n            }\n        };\n        pseudoinput.click();\n    };\n\n    const exportSettings = async () => {\n        return await downloadFile({\n            file: new File(\n                [JSON.stringify($storedSettings, null, 4)],\n                \"settings.json\", { type: \"application/json\" }\n            ),\n        });\n    };\n</script>\n\n<div class=\"button-row\" id=\"settings-data-transfer\">\n    <DataSettingsButton id=\"import-settings\" click={importSettings}>\n        <IconFileImport />\n        {$t(\"button.import\")}\n    </DataSettingsButton>\n\n    {#if $storedSettings.schemaVersion}\n        <DataSettingsButton id=\"export-settings\" click={exportSettings}>\n            <IconFileExport />\n            {$t(\"button.export\")}\n        </DataSettingsButton>\n    {/if}\n\n    {#if $storedSettings.schemaVersion}\n        <ResetSettingsButton />\n    {/if}\n</div>\n\n<style>\n    .button-row {\n        display: flex;\n        gap: var(--padding);\n        flex-wrap: wrap;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/settings/ResetSettingsButton.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n    import { createDialog } from \"$lib/state/dialogs\";\n    import { resetSettings } from \"$lib/state/settings\";\n\n    import IconRestore from \"@tabler/icons-svelte/IconRestore.svelte\";\n    import DataSettingsButton from \"$components/settings/DataSettingsButton.svelte\";\n\n    const resetDialog = () => {\n        createDialog({\n            id: \"wipe-confirm\",\n            type: \"small\",\n            icon: \"warn-red\",\n            title: $t(\"dialog.reset_settings.title\"),\n            bodyText: $t(\"dialog.reset_settings.body\"),\n            buttons: [\n                {\n                    text: $t(\"button.cancel\"),\n                    main: false,\n                    action: () => {},\n                },\n                {\n                    text: $t(\"button.reset\"),\n                    color: \"red\",\n                    main: true,\n                    timeout: 2000,\n                    action: () => resetSettings(),\n                },\n            ],\n        });\n    };\n</script>\n\n<DataSettingsButton id=\"reset-settings\" click={resetDialog} danger>\n    <IconRestore />\n    {$t(\"button.reset\")}\n</DataSettingsButton>\n"
  },
  {
    "path": "web/src/components/settings/SettingsCategory.svelte",
    "content": "<script lang=\"ts\">\n    import { page } from \"$app/stores\";\n    import SectionHeading from \"$components/misc/SectionHeading.svelte\";\n\n    export let title: string;\n    export let sectionId: string;\n\n    export let disabled = false;\n    export let beta = false;\n\n    let focus = false;\n    let copied = false;\n\n    $: hash = $page.url.hash.replace(\"#\", \"\");\n\n    $: if (hash === sectionId) {\n        focus = true;\n    }\n\n    $: if (copied) {\n        setTimeout(() => {\n            copied = false;\n        }, 1500);\n    }\n</script>\n\n<section\n    id={sectionId}\n    class=\"settings-content\"\n    class:focus\n    class:disabled\n    aria-hidden={disabled}\n>\n    <SectionHeading {title} {sectionId} {beta} />\n    <slot></slot>\n</section>\n\n<style>\n    .settings-content {\n        display: flex;\n        flex-direction: column;\n        gap: 10px;\n        padding: calc(var(--subnav-padding) / 2);\n        border-radius: 18px;\n        transition: opacity 0.2s;\n    }\n\n    .settings-content.disabled {\n        opacity: 0.5;\n        pointer-events: none;\n    }\n\n    /*\n        for some weird reason parent's transition\n        breaks final opacity of children on ios\n    */\n    :global([data-iphone=\"true\"]) .settings-content {\n        transition: none;\n    }\n\n    .settings-content.focus {\n        animation: highlight 2s;\n    }\n\n    :global([data-reduce-motion=\"true\"]) .settings-content.focus {\n        animation: highlight-lite 2s !important;\n    }\n\n    @keyframes highlight {\n        0% {\n            box-shadow: none;\n        }\n        10% {\n            box-shadow: 0 0 0 3.5px var(--blue) inset;\n        }\n        20%, 50% {\n            box-shadow: 0 0 0 3px var(--blue) inset;\n        }\n        100% {\n            box-shadow: none;\n        }\n    }\n\n    @keyframes highlight-lite {\n        0% {\n            box-shadow: none;\n        }\n        10%, 50% {\n            box-shadow: 0 0 0 3px var(--blue) inset;\n        }\n        100% {\n            box-shadow: none;\n        }\n    }\n\n    @media screen and (max-width: 750px) {\n        .settings-content {\n            padding: var(--padding);\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/settings/SettingsDropdown.svelte",
    "content": "<script\n    lang=\"ts\"\n    generics=\"\n        Context extends Exclude<keyof CobaltSettings, 'schemaVersion'>,\n        Id extends keyof CobaltSettings[Context]\n    \"\n>\n    import { updateSetting } from \"$lib/state/settings\";\n    import type { CobaltSettings } from \"$lib/types/settings\";\n\n    import { hapticConfirm, hapticSwitch } from \"$lib/haptics\";\n    import IconSelector from \"@tabler/icons-svelte/IconSelector.svelte\";\n\n    export let title: string;\n    export let description: string = \"\";\n    export let items: Record<string, string>;\n\n    export let settingId: Id;\n    export let settingContext: Context;\n\n    export let selectedOption: string;\n    export let selectedTitle: string;\n    export let disabled = false;\n\n    const onChange = (event: Event) => {\n        hapticConfirm();\n\n        const target = event.target as HTMLSelectElement;\n        updateSetting({\n            [settingContext]: {\n                [settingId]: target.value,\n            },\n        });\n    };\n</script>\n\n<div class=\"selector-parent\" class:disabled aria-hidden={disabled}>\n    <div id=\"selector\" class=\"selector button\">\n        <div class=\"selector-info\">\n            <h4 class=\"selector-title\">\n                {title}\n            </h4>\n            <div class=\"right-side\">\n                <span class=\"selector-current\" aria-hidden=\"true\">\n                    {selectedTitle?.split(\"(\", 2)[0]}\n                </span>\n                <IconSelector />\n            </div>\n        </div>\n\n        <select\n            on:click={() => hapticSwitch()}\n            on:change={(e) => onChange(e)}\n            {disabled}\n        >\n            {#each Object.keys(items) as value, i}\n                <option {value} selected={selectedOption === value}>\n                    {items[value]}\n                </option>\n                {#if i === 0}\n                    <hr />\n                {/if}\n            {/each}\n        </select>\n    </div>\n\n    {#if description}\n        <div class=\"subtext\">\n            {description}\n        </div>\n    {/if}\n</div>\n\n<style>\n    .selector-parent {\n        display: flex;\n        flex-direction: column;\n        gap: 10px;\n        overflow: hidden;\n        transition: opacity 0.2s;\n    }\n\n    .selector-parent.disabled {\n        opacity: 0.5;\n    }\n\n    #selector {\n        display: flex;\n        flex-direction: row;\n        align-items: center;\n        justify-content: space-between;\n        position: relative;\n\n        padding: calc(var(--switcher-padding) * 2) 16px;\n\n        pointer-events: all;\n        overflow: scroll;\n    }\n\n    .disabled #selector {\n        pointer-events: none;\n    }\n\n    .selector-info {\n        height: 100%;\n        width: 100%;\n        display: flex;\n        justify-content: space-between;\n        align-items: center;\n\n        gap: var(--padding);\n        min-height: 26px;\n\n        z-index: 2;\n        pointer-events: none;\n    }\n\n    .right-side {\n        display: flex;\n        align-items: center;\n        justify-content: flex-end;\n        float: right;\n        gap: calc(var(--padding) / 2);\n    }\n\n    .selector-current,\n    .selector select {\n        font-size: 13px;\n        font-weight: 400;\n        text-transform: lowercase;\n    }\n\n    .right-side :global(svg) {\n        stroke-width: 1.5px;\n        height: 20px;\n        width: 20px;\n        stroke: var(--secondary);\n    }\n\n    .selector select {\n        position: absolute;\n        width: 100%;\n        height: 100%;\n        background: none;\n        border: none;\n        left: 0;\n        border-radius: var(--border-radius);\n        cursor: pointer;\n        color: transparent;\n        text-align: right;\n\n        /* safari fix */\n        appearance: initial;\n        text-align-last: right;\n    }\n\n    /* fix for chrome on windows */\n    option {\n        color: initial;\n        text-align: initial;\n        text-align-last: initial;\n        border-radius: initial;\n        background: initial;\n        border: initial;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/settings/SettingsInput.svelte",
    "content": "<script\n    lang=\"ts\"\n    generics=\"\n        Context extends Exclude<keyof CobaltSettings, 'schemaVersion'>,\n        Id extends keyof CobaltSettings[Context]\n    \"\n>\n    import { get } from \"svelte/store\";\n    import { t } from \"$lib/i18n/translations\";\n    import type { CobaltSettings } from \"$lib/types/settings\";\n\n    import settings, { updateSetting } from \"$lib/state/settings\";\n    import { customInstanceWarning } from \"$lib/api/safety-warning\";\n\n    import IconX from \"@tabler/icons-svelte/IconX.svelte\";\n    import IconCheck from \"@tabler/icons-svelte/IconCheck.svelte\";\n    import IconArrowBack from \"@tabler/icons-svelte/IconArrowBack.svelte\";\n\n    import IconEye from \"@tabler/icons-svelte/IconEye.svelte\";\n    import IconEyeOff from \"@tabler/icons-svelte/IconEyeOff.svelte\";\n\n    type SettingsInputType = \"url\" | \"uuid\";\n\n    export let settingId: Id;\n    export let settingContext: Context;\n    export let placeholder: string;\n    export let altText: string;\n    export let type: \"url\" | \"uuid\" = \"url\";\n\n    export let sensitive = false;\n    export let showInstanceWarning = false;\n\n    const regex = {\n        uuid: \"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$\",\n    };\n\n    let input: HTMLInputElement;\n    let inputValue: string = String(get(settings)[settingContext][settingId]);\n    let inputFocused = false;\n    let validInput = true;\n\n    let inputHidden = true;\n\n    $: inputType = sensitive && inputHidden ? \"password\" : \"text\";\n\n    const checkInput = () => {\n        // mark input as valid if it's empty to allow wiping\n        if (inputValue.length === 0) {\n            validInput = true;\n            return;\n        }\n\n        if (type === \"url\") {\n            try {\n                new URL(inputValue)?.origin?.toString();\n                validInput = true;\n                return;\n            } catch {\n                validInput = false;\n                return;\n            }\n        } else {\n            validInput = new RegExp(regex[type]).test(inputValue);\n        }\n    };\n\n    const writeToSettings = (value: string, type: SettingsInputType) => {\n        // we assume that the url is valid and error can't be thrown here\n        // since it was tested before by checkInput()\n        updateSetting({\n            [settingContext]: {\n                [settingId]:\n                    type === \"url\" ? new URL(value).origin.toString() : value,\n            },\n        });\n        inputValue = String(get(settings)[settingContext][settingId]);\n    };\n\n    const save = async () => {\n        if (showInstanceWarning) {\n            await customInstanceWarning();\n\n            if ($settings.processing.seenCustomWarning) {\n                // fall back to uuid to allow writing empty strings\n                return writeToSettings(inputValue, inputValue ? type : \"uuid\");\n            }\n\n            return;\n        }\n\n        return writeToSettings(inputValue, type);\n    };\n</script>\n\n<div id=\"settings-input-holder\">\n    <div id=\"input-container\" class:focused={inputFocused} aria-hidden=\"false\">\n        <input\n            class=\"input-box\"\n            bind:this={input}\n            bind:value={inputValue}\n            on:input={() => {\n                inputFocused = true;\n                checkInput();\n            }}\n            on:focus={() => (inputFocused = true)}\n            on:blur={() => (inputFocused = false)}\n            spellcheck=\"false\"\n            autocomplete=\"off\"\n            autocapitalize=\"off\"\n            maxlength=\"64\"\n            aria-label={altText}\n            aria-hidden=\"false\"\n            aria-invalid={!validInput}\n            {...{ type: inputType }}\n        />\n\n        {#if inputValue.length > 0}\n            <button\n                class=\"button input-inner-button\"\n                on:click={() => {\n                    inputValue = \"\";\n                    checkInput();\n                }}\n                aria-label={$t(\"button.clear_input\")}\n            >\n                <IconX />\n            </button>\n\n            {#if sensitive}\n                <button\n                    class=\"button input-inner-button\"\n                    on:click={() => (inputHidden = !inputHidden)}\n                    aria-label={$t(\n                        inputHidden\n                            ? \"button.show_input\"\n                            : \"button.hide_input\"\n                    )}\n                >\n                    {#if inputHidden}\n                        <IconEye />\n                    {:else}\n                        <IconEyeOff />\n                    {/if}\n                </button>\n            {/if}\n        {/if}\n\n        {#if inputValue.length === 0}\n            <span class=\"input-placeholder\" aria-hidden=\"true\">\n                {placeholder}\n            </span>\n\n            {#if String($settings[settingContext][settingId]).length > 0}\n                <button\n                    class=\"button input-inner-button\"\n                    on:click={() => {\n                        inputValue = String(\n                            $settings[settingContext][settingId]\n                        );\n                        checkInput();\n                    }}\n                    aria-label={$t(\"button.restore_input\")}\n                >\n                    <IconArrowBack />\n                </button>\n            {/if}\n        {/if}\n    </div>\n\n    <div class=\"input-outer-buttons\">\n        <button\n            class=\"button settings-input-button\"\n            aria-label={$t(\"button.save\")}\n            disabled={inputValue === $settings[settingContext][settingId] ||\n                !validInput}\n            on:click={save}\n        >\n            <IconCheck />\n        </button>\n    </div>\n</div>\n\n<style>\n    #settings-input-holder {\n        display: flex;\n        gap: 6px;\n    }\n\n    #input-container {\n        border-radius: var(--border-radius);\n        color: var(--secondary);\n        background-color: var(--button);\n        box-shadow: var(--button-box-shadow);\n        display: flex;\n        align-items: center;\n        width: 100%;\n        position: relative;\n        overflow: hidden;\n    }\n\n    #input-container,\n    .input-box {\n        font-size: 13px;\n        font-weight: 500;\n        min-width: 0;\n    }\n\n    .input-box {\n        flex: 1;\n        background-color: transparent;\n        color: var(--secondary);\n        border: none;\n        padding-block: 0;\n        padding-inline: 0;\n        padding: 11.5px 0;\n    }\n\n    .input-placeholder {\n        position: absolute;\n        color: var(--gray);\n        pointer-events: none;\n        white-space: nowrap;\n    }\n\n    .input-box,\n    .input-placeholder {\n        padding-left: 16px;\n    }\n\n    #input-container.focused {\n        box-shadow: 0 0 0 2px var(--secondary) inset;\n    }\n\n    .input-outer-buttons {\n        display: flex;\n        flex-direction: row;\n        gap: 6px;\n    }\n\n    .settings-input-button {\n        width: 40px;\n        padding: 0;\n    }\n\n    .settings-input-button :global(svg) {\n        height: 21px;\n        width: 21px;\n        stroke-width: 1.8px;\n    }\n\n    .settings-input-button[disabled] {\n        opacity: 0.5;\n        pointer-events: none;\n    }\n\n    .input-inner-button {\n        height: 34px;\n        width: 34px;\n        padding: 0;\n        box-shadow: none;\n        /* 3px is visual padding outside of the button */\n        border-radius: calc(var(--border-radius) - 3px);\n        z-index: 1;\n    }\n\n    .input-inner-button:last-child {\n        margin-right: 3px;\n    }\n\n    .input-inner-button :global(svg) {\n        height: 18px;\n        width: 18px;\n        stroke-width: 1.8px;\n    }\n\n    :global(svg) {\n        will-change: transform;\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/sidebar/CobaltLogo.svelte",
    "content": "<script>\n    import IconCobalt from \"$components/icons/Cobalt.svelte\";\n</script>\n\n<div id=\"cobalt-logo\">\n    <IconCobalt />\n</div>\n\n<style>\n    #cobalt-logo {\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        padding: calc(var(--sidebar-tab-padding) * 2);\n\n        /* accommodate space for scaling animation */\n        padding-bottom: calc(var(--sidebar-tab-padding) * 2 - var(--sidebar-inner-padding));\n    }\n\n    #cobalt-logo :global(path) {\n        fill: var(--sidebar-highlight);\n    }\n\n    @media screen and (max-width: 535px) {\n        #cobalt-logo {\n            display: none;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/sidebar/Sidebar.svelte",
    "content": "<script lang=\"ts\">\n    import settings from \"$lib/state/settings\";\n\n    import { t } from \"$lib/i18n/translations\";\n    import { defaultNavPage } from \"$lib/subnav\";\n\n    import CobaltLogo from \"$components/sidebar/CobaltLogo.svelte\";\n    import SidebarTab from \"$components/sidebar/SidebarTab.svelte\";\n\n    import IconDownload from \"@tabler/icons-svelte/IconDownload.svelte\";\n    import IconSettings from \"@tabler/icons-svelte/IconSettings.svelte\";\n\n    import IconRepeat from \"@tabler/icons-svelte/IconRepeat.svelte\";\n\n    import IconComet from \"@tabler/icons-svelte/IconComet.svelte\";\n    import IconHeart from \"@tabler/icons-svelte/IconHeart.svelte\";\n    import IconInfoCircle from \"@tabler/icons-svelte/IconInfoCircle.svelte\";\n\n    let screenWidth: number;\n    let settingsLink = defaultNavPage(\"settings\");\n    let aboutLink = defaultNavPage(\"about\");\n\n    $: screenWidth,\n        (settingsLink = defaultNavPage(\"settings\")),\n        (aboutLink = defaultNavPage(\"about\"));\n</script>\n\n<svelte:window bind:innerWidth={screenWidth} />\n\n<nav id=\"sidebar\" aria-label={$t(\"a11y.tabs.tab_panel\")}>\n    <CobaltLogo />\n    <div id=\"sidebar-tabs\" role=\"tablist\">\n        <div id=\"sidebar-actions\" class=\"sidebar-inner-container\">\n            <SidebarTab name=\"save\" path=\"/\" icon={IconDownload} />\n            {#if !$settings.appearance.hideRemuxTab}\n                <SidebarTab name=\"remux\" path=\"/remux\" icon={IconRepeat} beta />\n            {/if}\n        </div>\n        <div id=\"sidebar-info\" class=\"sidebar-inner-container\">\n            <SidebarTab name=\"settings\" path={settingsLink} icon={IconSettings} />\n            <SidebarTab name=\"donate\" path=\"/donate\" icon={IconHeart} />\n            <SidebarTab name=\"updates\" path=\"/updates\" icon={IconComet} />\n            <SidebarTab name=\"about\" path={aboutLink} icon={IconInfoCircle} />\n        </div>\n    </div>\n</nav>\n\n<style>\n    #sidebar,\n    #sidebar-tabs,\n    .sidebar-inner-container {\n        display: flex;\n        flex-direction: column;\n    }\n\n    #sidebar {\n        background: var(--sidebar-bg);\n        height: 100vh;\n        width: calc(var(--sidebar-width) + var(--sidebar-inner-padding) * 2);\n        position: sticky;\n    }\n\n    #sidebar-tabs {\n        height: 100%;\n        justify-content: space-between;\n        padding: var(--sidebar-inner-padding);\n        padding-bottom: var(--sidebar-tab-padding);\n        overflow-y: scroll;\n    }\n\n    @media screen and (max-width: 535px) {\n        #sidebar,\n        #sidebar-tabs,\n        .sidebar-inner-container {\n            flex-direction: row;\n        }\n\n        #sidebar {\n            width: 100%;\n            height: var(--sidebar-height-mobile);\n            position: fixed;\n            bottom: 0;\n            justify-content: center;\n            align-items: flex-start;\n            z-index: 3;\n            padding: var(--sidebar-inner-padding) 0;\n        }\n\n        #sidebar::before {\n            content: \"\";\n            z-index: 1;\n            width: 100%;\n            height: 100%;\n            display: block;\n            position: absolute;\n            pointer-events: none;\n            background: var(--sidebar-mobile-gradient);\n        }\n\n        #sidebar-tabs {\n            overflow-y: visible;\n            overflow-x: scroll;\n            padding: 0;\n            height: fit-content;\n        }\n\n        #sidebar :global(.sidebar-inner-container:first-child) {\n            padding-left: calc(var(--border-radius) * 1.5);\n        }\n\n        #sidebar :global(.sidebar-inner-container:last-child) {\n            padding-right: calc(var(--border-radius) * 1.5);\n        }\n\n        #sidebar :global(.sidebar-inner-container:first-child:dir(rtl)) {\n            padding-left: 0;\n            padding-right: calc(var(--border-radius) * 1.5);\n        }\n\n        #sidebar :global(.sidebar-inner-container:last-child:dir(rtl)) {\n            padding-right: 0;\n            padding-left: calc(var(--border-radius) * 1.5);\n        }\n    }\n\n    /* add padding for notch / dynamic island in landscape */\n    @media screen and (orientation: landscape) {\n        :global([data-iphone=\"true\"]) #sidebar {\n            padding-left: env(safe-area-inset-left);\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/sidebar/SidebarTab.svelte",
    "content": "<script lang=\"ts\">\n    import { page } from \"$app/stores\";\n\n    import { t } from \"$lib/i18n/translations\";\n\n    export let name: string;\n    export let path: string;\n    export let icon: ConstructorOfATypedSvelteComponent;\n\n    export let beta = false;\n\n    const firstTabPage = [\"save\", \"remux\", \"settings\"];\n\n    let tab: HTMLElement;\n\n    $: currentTab = $page.url.pathname.split(\"/\")[1];\n    $: baseTabPath = path.split(\"/\")[1];\n\n    $: isTabActive = currentTab === baseTabPath;\n\n    const showTab = (e: HTMLElement) => {\n        if (e) {\n            e.scrollIntoView({\n                inline: firstTabPage.includes(name) ? \"end\" : \"start\",\n                block: \"nearest\",\n                behavior: \"smooth\",\n            });\n        }\n    };\n\n    $: if (isTabActive && tab) {\n        showTab(tab);\n    }\n</script>\n\n<a\n    id=\"sidebar-tab-{name}\"\n    class=\"sidebar-tab\"\n    class:active={isTabActive}\n    href={path}\n    bind:this={tab}\n    on:focus={() => showTab(tab)}\n    role=\"tab\"\n    aria-selected={isTabActive}\n>\n    {#if beta}\n        <div class=\"beta-sign\" aria-label={$t(\"general.beta\")}>β</div>\n    {/if}\n\n    <svelte:component this={icon} />\n    <span class=\"tab-title\">{$t(`tabs.${name}`)}</span>\n</a>\n\n<style>\n    .sidebar-tab {\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n        text-align: center;\n        gap: 3px;\n        padding: var(--sidebar-tab-padding) 3px;\n        color: var(--sidebar-highlight);\n        font-size: var(--sidebar-font-size);\n        opacity: 0.75;\n        height: fit-content;\n        border-radius: var(--border-radius);\n        transition: transform 0.2s;\n\n        text-decoration: none;\n        text-decoration-line: none;\n        position: relative;\n        scroll-behavior: smooth;\n\n        cursor: pointer;\n    }\n\n    .sidebar-tab :global(svg) {\n        stroke-width: 1.2px;\n        height: 22px;\n        width: 22px;\n    }\n\n    :global([data-iphone=\"true\"] .sidebar-tab svg) {\n        will-change: transform;\n    }\n\n    .sidebar-tab.active {\n        color: var(--sidebar-bg);\n        background: var(--sidebar-highlight);\n        opacity: 1;\n        transform: none;\n        transition: none;\n        animation: pressButton 0.3s;\n        cursor: default;\n    }\n\n    .sidebar-tab:not(.active):active {\n        transform: scale(0.95);\n    }\n\n    :global([data-reduce-motion=\"true\"]) .sidebar-tab:active {\n        transform: none;\n    }\n\n    .beta-sign {\n        position: absolute;\n        transform: translateX(16px) translateY(-6px);\n        opacity: 0.7;\n    }\n\n    .tab-title {\n        white-space: nowrap;\n    }\n\n    .sidebar-tab:active:not(.active) {\n        opacity: 1;\n    }\n\n    @keyframes pressButton {\n        0% {\n            transform: scale(0.9);\n        }\n        50% {\n            transform: scale(1.015);\n        }\n        100% {\n            transform: scale(1);\n        }\n    }\n\n    @media (hover: hover) {\n        .sidebar-tab:hover:not(.active) {\n            background-color: var(--button-hover-transparent);\n        }\n\n        .sidebar-tab:active:not(.active),\n        .sidebar-tab:focus:hover:not(.active) {\n            background-color: var(--button-press-transparent);\n        }\n\n        .sidebar-tab:hover:not(.active) {\n            opacity: 1;\n        }\n\n        .sidebar-tab:active:not(.active),\n        .sidebar-tab:focus:hover:not(.active) {\n            opacity: 1;\n            box-shadow: 0 0 0 1px var(--sidebar-stroke) inset;\n        }\n    }\n\n    @media screen and (max-width: 535px) {\n        .sidebar-tab {\n            padding: 5px var(--padding);\n            min-width: calc(var(--sidebar-width) / 2);\n        }\n\n        .sidebar-tab.active {\n            z-index: 2;\n        }\n\n        .sidebar-tab:active:not(.active) {\n            transform: scale(0.9);\n        }\n\n        @keyframes pressButton {\n            0% {\n                transform: scale(0.8);\n            }\n            50% {\n                transform: scale(1.02);\n            }\n            100% {\n                transform: scale(1);\n            }\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/subnav/PageNav.svelte",
    "content": "<script lang=\"ts\">\n    import { page } from \"$app/stores\";\n    import { goto } from \"$app/navigation\";\n    import { browser } from \"$app/environment\";\n    import { defaultNavPage } from \"$lib/subnav\";\n\n    import { t } from \"$lib/i18n/translations\";\n\n    import IconArrowLeft from \"@tabler/icons-svelte/IconArrowLeft.svelte\";\n\n    export let pageName: \"settings\" | \"about\";\n    export let homeNavPath: string;\n    export let homeTitle: string;\n    export let pageSubtitle = \"\";\n    export let contentPadding = false;\n    export let wideContent = false;\n\n    let screenWidth: number;\n\n    $: currentPageTitle = $page.url.pathname.split(\"/\").pop();\n    $: stringPageTitle =\n        currentPageTitle !== pageName\n            ? ` / ${$t(`${pageName}.page.${currentPageTitle}`)}`\n            : \"\";\n\n    $: isMobile = screenWidth <= 750;\n    $: isHome = $page.url.pathname === homeNavPath;\n    $: {\n        if (browser && !isMobile && isHome) {\n            goto(defaultNavPage(pageName), { replaceState: true });\n        }\n    }\n</script>\n\n<svelte:head>\n    <title>\n        {homeTitle}{stringPageTitle} ~ {$t(\"general.cobalt\")}\n    </title>\n    <meta\n        property=\"og:title\"\n        content=\"{homeTitle}{stringPageTitle} ~ {$t('general.cobalt')}\"\n    />\n</svelte:head>\n\n<svelte:window bind:innerWidth={screenWidth} />\n\n<div id=\"{pageName}-page\" class=\"subnav-page\">\n    <div class=\"subnav-sidebar\" class:back-visible={!isHome && isMobile}>\n        <div class=\"subnav-header\">\n            {#if isMobile}\n                {#if !isHome}\n                    <a\n                        class=\"back-button\"\n                        href={homeNavPath}\n                        role=\"button\"\n                        aria-label={$t(\"a11y.general.back\")}\n                    >\n                        <IconArrowLeft />\n                    </a>\n                {/if}\n                <h3\n                    class=\"subnav-page-title\"\n                    aria-level=\"1\"\n                    tabindex=\"-1\"\n                    data-first-focus\n                >\n                    {#if !isHome}\n                        {$t(`${pageName}.page.${currentPageTitle}`)}\n                    {:else}\n                        {homeTitle}\n                    {/if}\n                </h3>\n            {:else}\n                {#if pageSubtitle}\n                    <div\n                        class=\"subtext subnav-subtitle\"\n                        class:hidden={pageSubtitle === \"\\xa0\"}\n                    >\n                        {pageSubtitle}\n                    </div>\n                {/if}\n                <h2 class=\"subnav-page-title\" aria-level=\"1\">\n                    {homeTitle}\n                </h2>\n            {/if}\n        </div>\n\n        <nav\n            class=\"subnav-navigation\"\n            class:visible-mobile={isMobile && isHome}\n        >\n            <slot name=\"navigation\"></slot>\n            {#if isMobile && isHome && pageSubtitle}\n                <div\n                    class=\"subtext subnav-subtitle center\"\n                    class:hidden={pageSubtitle === \"\\xa0\"}\n                >\n                    {pageSubtitle}\n                </div>\n            {/if}\n        </nav>\n    </div>\n\n    {#if !isMobile || !isHome}\n        <main\n            id=\"{pageName}-page-content\"\n            class=\"subnav-page-content\"\n            class:padding={contentPadding}\n            class:wide={wideContent}\n            tabindex=\"-1\"\n            data-first-focus\n        >\n            <slot name=\"content\"></slot>\n        </main>\n    {/if}\n</div>\n\n<style>\n    .subnav-page {\n        --subnav-nav-width: 250px;\n        --subnav-padding: 26px;\n        --subnav-padding-small: calc(var(--subnav-padding) - var(--padding));\n        display: grid;\n        width: 100%;\n        grid-template-columns: var(--subnav-nav-width) 1fr;\n        overflow: hidden;\n        padding-left: var(--subnav-padding);\n        column-gap: calc(var(--subnav-padding) / 2);\n    }\n\n    .subnav-page:dir(rtl) {\n        padding-left: 0;\n        padding-right: var(--subnav-padding);\n    }\n\n    .subnav-page-content {\n        display: flex;\n        flex-direction: column;\n        max-width: 600px;\n        padding: calc(var(--subnav-padding) / 2);\n        overflow-y: scroll;\n    }\n\n    .subnav-page-content.wide {\n        max-width: 700px;\n    }\n\n    .subnav-page-content.padding {\n        padding: var(--subnav-padding);\n    }\n\n    .subnav-sidebar,\n    .subnav-navigation {\n        display: flex;\n        flex-direction: column;\n        overflow-y: scroll;\n    }\n\n    .subnav-sidebar {\n        width: var(--subnav-nav-width);\n        padding-top: var(--subnav-padding);\n    }\n\n    .subnav-sidebar.back-visible {\n        overflow: visible;\n    }\n\n    .subnav-sidebar {\n        gap: var(--padding);\n    }\n\n    .subnav-navigation {\n        gap: var(--padding);\n        padding-bottom: var(--padding);\n    }\n\n    .subnav-header {\n        --back-padding: calc(var(--padding) / 2);\n    }\n\n    .subnav-subtitle {\n        padding: 0;\n        transition: opacity 0.1s;\n    }\n\n    .subnav-subtitle.hidden {\n        opacity: 0;\n    }\n\n    .subnav-subtitle.center {\n        text-align: center;\n    }\n\n    .back-button {\n        display: flex;\n        align-items: center;\n        color: var(--secondary);\n        gap: var(--back-padding);\n        padding: var(--back-padding);\n\n        position: absolute;\n        left: var(--back-padding);\n    }\n\n    .back-button:active {\n        background: var(--button-hover-transparent);\n        border-radius: var(--border-radius);\n    }\n\n    .back-button :global(svg) {\n        stroke-width: 1.8px;\n        height: 22px;\n        width: 22px;\n        will-change: transform;\n    }\n\n    @media screen and (max-width: 1000px) {\n        .subnav-page {\n            column-gap: 0;\n        }\n    }\n\n    @media screen and (max-width: 750px) {\n        .subnav-page,\n        .subnav-page:dir(rtl) {\n            --subnav-nav-width: 100%;\n            display: flex;\n            flex-direction: column;\n            grid-template-columns: 1fr;\n            padding: 0;\n        }\n\n        .subnav-navigation {\n            padding: var(--padding);\n            padding-bottom: calc(var(--padding) * 2);\n            display: none;\n        }\n\n        .subnav-navigation.visible-mobile {\n            display: flex;\n        }\n\n        .subnav-page-content {\n            padding: var(--padding) 0;\n            padding-top: 0;\n            max-width: unset;\n        }\n\n        .subnav-page-content.padding {\n            padding: var(--padding);\n        }\n\n        .subnav-header {\n            display: flex;\n            align-items: center;\n            position: sticky;\n            padding: var(--padding);\n            gap: 4px;\n            justify-content: center;\n        }\n\n        .subnav-sidebar {\n            gap: 0px;\n            padding: 0;\n        }\n\n        .subnav-page-title {\n            text-align: center;\n            letter-spacing: -0.3px;\n            font-size: 16.5px;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/subnav/PageNavSection.svelte",
    "content": "<script lang=\"ts\">\n    export let sectionTitle: string = \"\";\n</script>\n\n<div id=\"subnav-section\">\n    {#if sectionTitle}\n        <div id=\"subnav-section-title\">\n            {sectionTitle}\n        </div>\n    {/if}\n    <div id=\"subnav-section-categories\">\n        <slot></slot>\n    </div>\n</div>\n\n<style>\n    #subnav-section,\n    #subnav-section-categories {\n        display: flex;\n        flex-direction: column;\n    }\n\n    #subnav-section {\n        gap: 6px;\n        border-radius: var(--border-radius);\n    }\n\n    #subnav-section-title {\n        font-size: 12.5px;\n        font-weight: 500;\n        color: var(--gray);\n        padding-left: 8px;\n    }\n\n    @media screen and (max-width: 750px) {\n        #subnav-section-categories {\n            background: var(--button);\n            border-radius: var(--border-radius);\n            box-shadow: var(--button-box-shadow);\n            overflow-x: hidden;\n        }\n\n        #subnav-section-title {\n            padding-left: calc(7px * 1.5);\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/components/subnav/PageNavTab.svelte",
    "content": "<script lang=\"ts\">\n    import { page } from \"$app/stores\";\n\n    import IconChevronRight from \"@tabler/icons-svelte/IconChevronRight.svelte\";\n\n    export let path: string;\n    export let title: string;\n    export let icon: ConstructorOfATypedSvelteComponent;\n    export let iconColor: \"gray\" | \"blue\" | \"green\" | \"magenta\" | \"purple\" | \"orange\" = \"gray\";\n\n    $: isActive = $page.url.pathname === path;\n</script>\n\n<a\n    class=\"subnav-tab\"\n    href={path}\n    class:active={isActive}\n    role=\"button\"\n>\n    <div class=\"subnav-tab-left\" style=\"--icon-color: var(--{iconColor})\">\n        <div class=\"tab-icon\">\n            <svelte:component this={icon} />\n        </div>\n        <div class=\"subnav-tab-text\">\n            {title}\n        </div>\n    </div>\n    <div class=\"subnav-tab-chevron\">\n        <IconChevronRight />\n    </div>\n</a>\n\n<style>\n    .subnav-tab {\n        --small-padding: 4px;\n        --big-padding: 6px;\n        display: flex;\n        flex-direction: row;\n        align-items: center;\n        justify-content: space-between;\n        gap: calc(var(--small-padding) * 2);\n        padding: var(--big-padding);\n        font-weight: 500;\n        color: var(--button-text);\n        border-radius: var(--border-radius);\n        overflow: hidden;\n\n        text-decoration: none;\n        text-decoration-line: none;\n\n        cursor: pointer;\n    }\n\n    .subnav-tab-left {\n        display: flex;\n        flex-direction: row;\n        align-items: center;\n        gap: calc(var(--big-padding) * 1.5);\n        font-weight: 500;\n    }\n\n    .tab-icon {\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        padding: var(--small-padding);\n        border-radius: 5px;\n        background: var(--icon-color);\n    }\n\n    .subnav-tab .tab-icon :global(svg) {\n        stroke-width: 1.5px;\n        stroke: var(--white);\n        height: 20px;\n        width: 20px;\n    }\n\n    .subnav-tab:not(.active) .tab-icon {\n        background: rgba(0, 0, 0, 0.05);\n        box-shadow: var(--button-box-shadow);\n    }\n\n    :global([data-theme=\"dark\"]) .subnav-tab:not(.active) .tab-icon {\n        background: rgba(255, 255, 255, 0.1);\n    }\n\n    .subnav-tab:not(.active) .tab-icon :global(svg) {\n        stroke: var(--icon-color);\n    }\n\n    .subnav-tab-chevron :global(svg) {\n        display: none;\n        stroke-width: 2px;\n        stroke: var(--gray);\n        height: 18px;\n        width: 18px;\n    }\n\n    .subnav-tab-chevron:dir(rtl) {\n        transform: scale(-1, 1);\n    }\n\n    @media (hover: hover) {\n        .subnav-tab:hover {\n            background: var(--button-hover-transparent);\n        }\n    }\n\n    .subnav-tab:active,\n    .subnav-tab:focus:hover:not(.active) {\n        background: var(--button-press-transparent);\n        box-shadow: var(--button-box-shadow);\n    }\n\n    .subnav-tab.active {\n        background: var(--secondary);\n        color: var(--primary);\n        cursor: default;\n    }\n\n    .subnav-tab-text {\n        font-size: 14.5px;\n        line-height: 1.35;\n    }\n\n    @media screen and (max-width: 750px) {\n        .subnav-tab {\n            --big-padding: 7px;\n            background: none;\n            padding: var(--big-padding) 11px;\n        }\n\n        .subnav-tab:not(:last-child) {\n            border-bottom-left-radius: 0;\n            border-bottom-right-radius: 0;\n            box-shadow: 48px 3px 0px -2px var(--button-stroke);\n        }\n\n        .subnav-tab:not(:first-child) {\n            border-top-left-radius: 0;\n            border-top-right-radius: 0;\n        }\n\n        .subnav-tab-left {\n            gap: 10px;\n        }\n\n        .subnav-tab-chevron :global(svg) {\n            display: block;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/fonts/noto-mono-cobalt.css",
    "content": "@font-face {\n    font-family: \"Noto Sans Mono\";\n    font-style: normal;\n    font-display: swap;\n    font-weight: 400;\n    src: url(/fonts/noto-mono-cobalt.woff2) format(\"woff2\");\n}\n"
  },
  {
    "path": "web/src/lib/api/api-url.ts",
    "content": "import env from \"$lib/env\";\nimport { get } from \"svelte/store\";\nimport settings from \"$lib/state/settings\";\n\nexport const currentApiURL = () => {\n    const processingSettings = get(settings).processing;\n    const customInstanceURL = processingSettings.customInstanceURL;\n\n    if (processingSettings.enableCustomInstances && customInstanceURL.length > 0) {\n        return new URL(customInstanceURL).origin;\n    }\n\n    return new URL(env.DEFAULT_API!).origin;\n}\n"
  },
  {
    "path": "web/src/lib/api/api.ts",
    "content": "import { get } from \"svelte/store\";\n\nimport settings from \"$lib/state/settings\";\n\nimport { getSession, resetSession } from \"$lib/api/session\";\nimport { currentApiURL } from \"$lib/api/api-url\";\nimport { turnstileEnabled, turnstileSolved } from \"$lib/state/turnstile\";\nimport cachedInfo from \"$lib/state/server-info\";\nimport { getServerInfo } from \"$lib/api/server-info\";\n\nimport type { Optional } from \"$lib/types/generic\";\nimport type { CobaltAPIResponse, CobaltErrorResponse, CobaltSaveRequestBody } from \"$lib/types/api\";\n\nconst waitForTurnstile = async () => {\n    return await new Promise((resolve, reject) => {\n        const unsub = turnstileSolved.subscribe((solved) => {\n            if (solved) {\n                unsub();\n                resolve(true);\n            }\n        });\n\n        // wait for turnstile to finish for 15 seconds\n        setTimeout(() => {\n            unsub();\n            reject(false);\n        }, 15 * 1000)\n    });\n}\n\nconst getAuthorization = async () => {\n    const processing = get(settings).processing;\n    if (processing.enableCustomApiKey && processing.customApiKey.length > 0) {\n        return `Api-Key ${processing.customApiKey}`;\n    }\n\n    if (!get(turnstileEnabled)) {\n        return;\n    }\n\n    if (!get(turnstileSolved)) {\n        try {\n            await waitForTurnstile();\n        } catch {\n            return {\n                status: \"error\",\n                error: {\n                    code: \"error.captcha_too_long\"\n                }\n            } as CobaltErrorResponse;\n        }\n    }\n\n    const session = await getSession();\n\n    if (session) {\n        if (\"error\" in session) {\n            if (session.error.code !== \"error.api.auth.not_configured\") {\n                return session;\n            }\n        } else {\n            return `Bearer ${session.token}`;\n        }\n    }\n}\n\nconst request = async (requestBody: CobaltSaveRequestBody, justRetried = false) => {\n    await getServerInfo();\n\n    const getCachedInfo = get(cachedInfo);\n\n    if (!getCachedInfo) {\n        return {\n            status: \"error\",\n            error: {\n                code: \"error.api.unreachable\"\n            }\n        } as CobaltErrorResponse;\n    }\n\n    const api = currentApiURL();\n    const authorization = await getAuthorization();\n\n    if (authorization && typeof authorization !== \"string\") {\n        return authorization;\n    }\n\n    let extraHeaders = {};\n\n    if (authorization) {\n        extraHeaders = {\n            \"Authorization\": authorization\n        }\n    }\n\n    const response: Optional<CobaltAPIResponse> = await fetch(api, {\n        method: \"POST\",\n        redirect: \"manual\",\n        signal: AbortSignal.timeout(20000),\n        body: JSON.stringify(requestBody),\n        headers: {\n            \"Accept\": \"application/json\",\n            \"Content-Type\": \"application/json\",\n            ...extraHeaders,\n        },\n    })\n    .then(r => r.json())\n    .catch((e) => {\n        if (e?.message?.includes(\"timed out\")) {\n            return {\n                status: \"error\",\n                error: {\n                    code: \"error.api.timed_out\"\n                }\n            } as CobaltErrorResponse;\n        }\n    });\n\n    if (\n        response?.status === 'error'\n            && response?.error.code === 'error.api.auth.jwt.invalid'\n            && !justRetried\n    ) {\n        resetSession();\n        await getAuthorization();\n        return request(requestBody, true);\n    }\n\n    return response;\n}\n\nconst probeCobaltTunnel = async (url: string) => {\n    const request = await fetch(`${url}&p=1`).catch(() => {});\n    if (request?.status === 200) {\n        return request?.status;\n    }\n    return 0;\n}\n\nexport default {\n    request,\n    probeCobaltTunnel,\n}\n"
  },
  {
    "path": "web/src/lib/api/safety-warning.ts",
    "content": "import { get } from \"svelte/store\";\nimport { t } from \"$lib/i18n/translations\";\nimport settings, { updateSetting } from \"$lib/state/settings\";\n\nimport { createDialog } from \"$lib/state/dialogs\";\n\nexport const customInstanceWarning = async () => {\n    if (get(settings).processing.seenCustomWarning) {\n        return;\n    }\n\n    let _actions: {\n        resolve: () => void;\n        reject: () => void;\n    };\n\n    const promise = new Promise<void>(\n        (resolve, reject) => (_actions = { resolve, reject })\n    ).catch(() => {\n        return {}\n    });\n\n    createDialog({\n        id: \"security-api-custom\",\n        type: \"small\",\n        icon: \"warn-red\",\n        title: get(t)(\"dialog.safety.title\"),\n        bodyText: get(t)(\"dialog.safety.custom_instance.body\"),\n        leftAligned: true,\n        buttons: [\n            {\n                text: get(t)(\"button.cancel\"),\n                main: false,\n                action: () => {\n                    _actions.reject();\n                },\n            },\n            {\n                text: get(t)(\"button.continue\"),\n                color: \"red\",\n                main: true,\n                timeout: 5000,\n                action: () => {\n                    _actions.resolve();\n                    updateSetting({\n                        processing: {\n                            seenCustomWarning: true,\n                        },\n                    })\n                },\n            },\n        ],\n    })\n\n    await promise;\n}\n"
  },
  {
    "path": "web/src/lib/api/saving-handler.ts",
    "content": "import env from \"$lib/env\";\nimport API from \"$lib/api/api\";\nimport settings from \"$lib/state/settings\";\nimport lazySettingGetter from \"$lib/settings/lazy-get\";\n\nimport { get } from \"svelte/store\";\nimport { t } from \"$lib/i18n/translations\";\nimport { downloadFile } from \"$lib/download\";\nimport { createDialog } from \"$lib/state/dialogs\";\nimport { downloadButtonState } from \"$lib/state/omnibox\";\nimport { createSavePipeline } from \"$lib/task-manager/queue\";\n\nimport type { CobaltSaveRequestBody } from \"$lib/types/api\";\n\ntype SavingHandlerArgs = {\n    url?: string,\n    request?: CobaltSaveRequestBody,\n    oldTaskId?: string\n}\n\nexport const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerArgs) => {\n    downloadButtonState.set(\"think\");\n\n    const error = (errorText: string) => {\n        return createDialog({\n            id: \"save-error\",\n            type: \"small\",\n            meowbalt: \"error\",\n            buttons: [\n                {\n                    text: get(t)(\"button.gotit\"),\n                    main: true,\n                    action: () => {},\n                },\n            ],\n            bodyText: errorText,\n        });\n    }\n\n    const getSetting = lazySettingGetter(get(settings));\n\n    if (!request && !url) return;\n\n    const selectedRequest = request || {\n        url: url!,\n\n        // not lazy cuz default depends on device capabilities\n        localProcessing: get(settings).save.localProcessing,\n\n        alwaysProxy: getSetting(\"save\", \"alwaysProxy\"),\n        downloadMode: getSetting(\"save\", \"downloadMode\"),\n\n        subtitleLang: getSetting(\"save\", \"subtitleLang\"),\n        filenameStyle: getSetting(\"save\", \"filenameStyle\"),\n        disableMetadata: getSetting(\"save\", \"disableMetadata\"),\n\n        audioFormat: getSetting(\"save\", \"audioFormat\"),\n        audioBitrate: getSetting(\"save\", \"audioBitrate\"),\n        tiktokFullAudio: getSetting(\"save\", \"tiktokFullAudio\"),\n        youtubeDubLang: getSetting(\"save\", \"youtubeDubLang\"),\n        youtubeBetterAudio: getSetting(\"save\", \"youtubeBetterAudio\"),\n\n        videoQuality: getSetting(\"save\", \"videoQuality\"),\n        youtubeVideoCodec: getSetting(\"save\", \"youtubeVideoCodec\"),\n        youtubeVideoContainer: getSetting(\"save\", \"youtubeVideoContainer\"),\n        youtubeHLS: env.ENABLE_DEPRECATED_YOUTUBE_HLS ? getSetting(\"save\", \"youtubeHLS\") : undefined,\n\n        allowH265: getSetting(\"save\", \"allowH265\"),\n        convertGif: getSetting(\"save\", \"convertGif\"),\n    }\n\n    const response = await API.request(selectedRequest);\n\n    if (!response) {\n        downloadButtonState.set(\"error\");\n        return error(get(t)(\"error.api.unreachable\"));\n    }\n\n    if (response.status === \"error\") {\n        downloadButtonState.set(\"error\");\n\n        return error(\n            get(t)(response.error.code, response?.error?.context)\n        );\n    }\n\n    if (response.status === \"redirect\") {\n        downloadButtonState.set(\"done\");\n\n        return downloadFile({\n            url: response.url,\n            urlType: \"redirect\",\n        });\n    }\n\n    if (response.status === \"tunnel\") {\n        downloadButtonState.set(\"check\");\n\n        const probeResult = await API.probeCobaltTunnel(response.url);\n\n        if (probeResult === 200) {\n            downloadButtonState.set(\"done\");\n\n            return downloadFile({\n                url: response.url,\n            });\n        } else {\n            downloadButtonState.set(\"error\");\n            return error(get(t)(\"error.tunnel.probe\"));\n        }\n    }\n\n    if (response.status === \"local-processing\") {\n        downloadButtonState.set(\"done\");\n        return createSavePipeline(response, selectedRequest, oldTaskId);\n    }\n\n    if (response.status === \"picker\") {\n        downloadButtonState.set(\"done\");\n        const buttons = [\n            {\n                text: get(t)(\"button.done\"),\n                main: true,\n                action: () => { },\n            },\n        ];\n\n        if (response.audio) {\n            const pickerAudio = response.audio;\n            buttons.unshift({\n                text: get(t)(\"button.download.audio\"),\n                main: false,\n                action: () => {\n                    downloadFile({\n                        url: pickerAudio,\n                    });\n                },\n            });\n        }\n\n        return createDialog({\n            id: \"download-picker\",\n            type: \"picker\",\n            items: response.picker,\n            buttons,\n        });\n    }\n\n    downloadButtonState.set(\"error\");\n    return error(get(t)(\"error.api.unknown_response\"));\n}\n"
  },
  {
    "path": "web/src/lib/api/server-info.ts",
    "content": "import { browser } from \"$app/environment\";\n\nimport { get } from \"svelte/store\";\nimport { currentApiURL } from \"$lib/api/api-url\";\nimport { turnstileCreated, turnstileEnabled, turnstileSolved } from \"$lib/state/turnstile\";\nimport cachedInfo from \"$lib/state/server-info\";\nimport type { CobaltServerInfoResponse, CobaltErrorResponse, CobaltServerInfo } from \"$lib/types/api\";\n\nexport type CobaltServerInfoCache = {\n    info: CobaltServerInfo,\n    origin: string,\n}\n\nconst request = async () => {\n    const apiEndpoint = `${currentApiURL()}/`;\n\n    const response: CobaltServerInfoResponse = await fetch(apiEndpoint, {\n        redirect: \"manual\",\n        signal: AbortSignal.timeout(10000),\n    })\n    .then(r => r.json())\n    .catch((e) => {\n        if (e?.message?.includes(\"timed out\")) {\n            return {\n                status: \"error\",\n                error: {\n                    code: \"error.api.timed_out\"\n                }\n            } as CobaltErrorResponse\n        }\n    });\n\n    return response;\n}\n\n// reload the page if turnstile is now disabled, but was previously loaded and not solved\nconst reloadIfTurnstileDisabled = () => {\n    if (browser && !get(turnstileEnabled) && get(turnstileCreated) && !get(turnstileSolved)) {\n        window.location.reload();\n    }\n}\n\nexport const getServerInfo = async () => {\n    const cache = get(cachedInfo);\n\n    if (cache && cache.origin === currentApiURL()) {\n        reloadIfTurnstileDisabled();\n        return true\n    }\n\n    const freshInfo = await request();\n\n    if (!freshInfo || !(\"cobalt\" in freshInfo)) {\n        return false;\n    }\n\n    if (!(\"status\" in freshInfo)) {\n        cachedInfo.set({\n            info: freshInfo,\n            origin: currentApiURL(),\n        });\n\n        // reload the page if turnstile sitekey changed\n        if (browser && get(turnstileEnabled) && cache && cache?.info?.cobalt?.turnstileSitekey !== freshInfo?.cobalt?.turnstileSitekey) {\n            window.location.reload();\n        }\n\n        reloadIfTurnstileDisabled();\n\n        return true;\n    }\n\n    return false;\n}\n"
  },
  {
    "path": "web/src/lib/api/session.ts",
    "content": "import turnstile from \"$lib/api/turnstile\";\nimport { currentApiURL } from \"$lib/api/api-url\";\n\nimport type { CobaltSession, CobaltErrorResponse, CobaltSessionResponse } from \"$lib/types/api\";\n\nlet cache: CobaltSession | undefined;\n\nexport const requestSession = async () => {\n    const apiEndpoint = `${currentApiURL()}/session`;\n\n    let requestHeaders = {};\n\n    const turnstileResponse = turnstile.getResponse();\n    if (turnstileResponse) {\n        requestHeaders = {\n            \"cf-turnstile-response\": turnstileResponse\n        };\n    }\n\n    const response: CobaltSessionResponse = await fetch(apiEndpoint, {\n        method: \"POST\",\n        redirect: \"manual\",\n        signal: AbortSignal.timeout(10000),\n        headers: requestHeaders,\n    })\n    .then(r => r.json())\n    .catch((e) => {\n        if (e?.message?.includes(\"timed out\")) {\n            return {\n                status: \"error\",\n                error: {\n                    code: \"error.api.timed_out\"\n                }\n            } as CobaltErrorResponse\n        }\n    });\n\n    turnstile.reset();\n\n    return response;\n}\n\nexport const getSession = async () => {\n    const currentTime = () => Math.floor(new Date().getTime() / 1000);\n\n    if (cache?.token && cache?.exp - 2 > currentTime()) {\n        return cache;\n    }\n\n    const newSession = await requestSession();\n\n    if (!newSession) return {\n        status: \"error\",\n        error: {\n            code: \"error.api.unreachable\"\n        }\n    } as CobaltErrorResponse\n\n    if (!(\"status\" in newSession)) {\n        newSession.exp = currentTime() + newSession.exp;\n        cache = newSession;\n    }\n    return newSession;\n}\n\nexport const resetSession = () => cache = undefined;\n"
  },
  {
    "path": "web/src/lib/api/turnstile.ts",
    "content": "import { turnstileSolved } from \"$lib/state/turnstile\";\n\nconst getResponse = () => {\n    const turnstileElement = document.getElementById(\"turnstile-widget\");\n\n    if (turnstileElement) {\n        return window?.turnstile?.getResponse(turnstileElement);\n    }\n\n    return null;\n}\n\nconst reset = () => {\n    const turnstileElement = document.getElementById(\"turnstile-widget\");\n\n    if (turnstileElement) {\n        turnstileSolved.set(false);\n        return window?.turnstile?.reset(turnstileElement);\n    }\n\n    return null;\n}\n\nexport default {\n    getResponse,\n    reset,\n}\n"
  },
  {
    "path": "web/src/lib/changelogs.ts",
    "content": "import { compareVersions } from 'compare-versions';\n\nexport function getVersionFromPath(path: string) {\n    return path.split('/').pop()?.split('.md').shift()!;\n}\n\nexport function getAllChangelogs() {\n    const changelogImports = import.meta.glob(\"/changelogs/*.md\");\n\n    const sortedVersions = Object.keys(changelogImports)\n                                 .map(path => [path, getVersionFromPath(path)])\n                                 .sort(([, a], [, b]) => compareVersions(a, b));\n\n    const sortedChangelogs = sortedVersions.reduce(\n        (obj, [path, version]) => ({\n            [version]: changelogImports[path],\n            ...obj\n        }), {} as typeof changelogImports\n    );\n\n    return sortedChangelogs;\n}\n"
  },
  {
    "path": "web/src/lib/clipboard.ts",
    "content": "const allowedLinkTypes = new Set([\"text/plain\", \"text/uri-list\"]);\n\nexport const pasteLinkFromClipboard = async () => {\n    const clipboard = await navigator.clipboard.read();\n\n    if (clipboard?.length) {\n        const clipboardItem = clipboard[0];\n        for (const type of clipboardItem.types) {\n            if (allowedLinkTypes.has(type)) {\n                const blob = await clipboardItem.getType(type);\n                const blobText = await blob.text();\n\n                return blobText;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "web/src/lib/device.ts",
    "content": "import { browser } from \"$app/environment\";\n\nconst app = {\n    is: {\n        installed: false,\n    }\n}\n\nconst device = {\n    is: {\n        iPhone: false,\n        iPad: false,\n        iOS: false,\n        android: false,\n        mobile: false,\n    },\n    browser: {\n        chrome: false,\n        webkit: false,\n    },\n    prefers: {\n        language: \"en\",\n        reducedMotion: false,\n        reducedTransparency: false,\n    },\n    supports: {\n        share: false,\n        directDownload: false,\n        haptics: false,\n        defaultLocalProcessing: false,\n        multithreading: false,\n    },\n    userAgent: \"sveltekit server\",\n}\n\nif (browser) {\n    const ua = navigator.userAgent.toLowerCase();\n\n    const iPhone = ua.includes(\"iphone os\");\n    const iPad = !iPhone && ua.includes(\"mac os\") && navigator.maxTouchPoints > 0;\n\n    const iosVersion = Number(ua.match(/version\\/(\\d+)/)?.[1]);\n    const modernIOS = iPhone && iosVersion >= 18;\n\n    const iOS = iPhone || iPad;\n    const android = ua.includes(\"android\") || ua.includes(\"diordna\");\n\n    const installed = window.matchMedia('(display-mode: standalone)').matches;\n\n    app.is = {\n        installed,\n    };\n\n    device.is = {\n        mobile: iOS || android,\n        android,\n\n        iPhone,\n        iPad,\n        iOS,\n    };\n\n    device.browser = {\n        chrome: ua.includes(\"chrome/\"),\n        webkit: ua.includes(\"applewebkit/\")\n                && ua.includes(\"version/\")\n                && ua.includes(\"safari/\")\n                // this is the version of webkit that's hardcoded into chrome\n                // and indicates that the browser is not actually webkit\n                && !ua.includes(\"applewebkit/537.36\")\n    };\n\n    device.prefers = {\n        language: navigator.language.toLowerCase().slice(0, 2) || \"en\",\n        reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,\n        reducedTransparency: window.matchMedia('(prefers-reduced-transparency: reduce)').matches,\n    };\n\n    device.supports = {\n        share: navigator.share !== undefined,\n        directDownload: !(installed && iOS),\n\n        // not sure if vibrations feel the same on android,\n        // so they're enabled only on ios 18+ for now\n        haptics: modernIOS,\n\n        // enable local processing by default everywhere but android chrome\n        defaultLocalProcessing: !(device.is.android && device.browser.chrome),\n        multithreading: !iOS || iosVersion >= 18,\n    };\n\n    device.userAgent = navigator.userAgent;\n}\n\nexport { device, app };\n"
  },
  {
    "path": "web/src/lib/download.ts",
    "content": "import { get } from \"svelte/store\";\n\nimport settings from \"$lib/state/settings\";\n\nimport { device } from \"$lib/device\";\nimport { t } from \"$lib/i18n/translations\";\nimport { createDialog } from \"$lib/state/dialogs\";\n\nimport type { DialogInfo } from \"$lib/types/dialog\";\nimport type { CobaltFileUrlType } from \"$lib/types/api\";\n\ntype DownloadFileParams = {\n    url?: string,\n    file?: File,\n    urlType?: CobaltFileUrlType,\n}\n\ntype SavingDialogParams = {\n    url?: string,\n    file?: File,\n    body?: string,\n    urlType?: CobaltFileUrlType,\n}\n\nconst openSavingDialog = ({ url, file, body, urlType }: SavingDialogParams) => {\n    const dialogData: DialogInfo = {\n        type: \"saving\",\n        id: \"saving\",\n        file,\n        url,\n        urlType,\n    }\n    if (body) dialogData.bodyText = body;\n\n    createDialog(dialogData)\n}\n\nexport const openFile = (file: File) => {\n    const a = document.createElement(\"a\");\n    const url = URL.createObjectURL(file);\n\n    a.href = url;\n    a.download = file.name;\n    a.click();\n    setTimeout(() => URL.revokeObjectURL(url), 10_000);\n}\n\nexport const shareFile = async (file: File) => {\n    return await navigator?.share({\n        files: [ file ],\n    });\n}\n\nexport const openURL = (url: string) => {\n    if (!['http:', 'https:'].includes(new URL(url).protocol)) {\n        return alert('error: invalid url!');\n    }\n\n    const open = window.open(url, \"_blank\", \"noopener,noreferrer\");\n\n    /* if new tab got blocked by user agent, show a saving dialog */\n    if (!open) {\n        return openSavingDialog({\n            url,\n            body: get(t)(\"dialog.saving.blocked\")\n        });\n    }\n}\n\nexport const shareURL = async (url: string) => {\n    return await navigator?.share({ url });\n}\n\nexport const copyURL = async (url: string) => {\n    return await navigator?.clipboard?.writeText(url);\n}\n\nexport const downloadFile = ({ url, file, urlType }: DownloadFileParams) => {\n    if (!url && !file) throw new Error(\"attempted to download void\");\n\n    const pref = get(settings).save.savingMethod;\n\n    if (pref === \"ask\") {\n        return openSavingDialog({ url, file, urlType });\n    }\n\n    /*\n        user actions (such as invoke share, open new tab) have expiration.\n        in webkit, for example, that timeout is 5 seconds.\n        https://github.com/WebKit/WebKit/blob/b838f8bb/Source/WebCore/page/LocalDOMWindow.cpp#L167\n\n        navigator.userActivation.isActive makes sure that we're still able to\n        invoke an action without the user agent interrupting it.\n        if not, we show a saving dialog for user to re-invoke that action.\n\n        if browser is old or doesn't support this API, we just assume that it expired.\n    */\n    if (!navigator?.userActivation?.isActive) {\n        return openSavingDialog({\n            url,\n            file,\n            body: get(t)(\"dialog.saving.timeout\"),\n            urlType\n        });\n    }\n\n    try {\n        if (file) {\n            // 256mb cuz ram limit per tab is 384mb,\n            // and other stuff (such as libav) might have used some ram too\n            const iosFileShareSizeLimit = 1024 * 1024 * 256;\n\n            // this is required because we can't share big files\n            // on ios due to a very low ram limit\n            if (device.is.iOS) {\n                if (file.size < iosFileShareSizeLimit) {\n                    return shareFile(file);\n                } else {\n                    return openFile(file);\n                }\n            }\n\n            if (pref === \"share\" && device.supports.share) {\n                return shareFile(file);\n            } else if (pref === \"download\") {\n                return openFile(file);\n            }\n        }\n\n        if (url) {\n            if (pref === \"share\" && device.supports.share) {\n                return shareURL(url);\n            } else if (pref === \"download\" && device.supports.directDownload\n                    && !(device.is.iOS && urlType === \"redirect\")) {\n                return openURL(url);\n            } else if (pref === \"copy\" && !file) {\n                return copyURL(url);\n            }\n        }\n    } catch { /* catch & ignore */ }\n\n    return openSavingDialog({ url, file, urlType });\n}\n"
  },
  {
    "path": "web/src/lib/env.ts",
    "content": "import * as _env from \"$env/static/public\";\n\nconst getEnv = (_key: string) => {\n    const env = _env as Record<string, string | undefined>;\n    const key = `WEB_${_key}`;\n\n    if (key in env) {\n        return env[key];\n    }\n}\n\nconst getEnvBool = (key: string) => {\n    const value = getEnv(key);\n    return value && ['1', 'true'].includes(value.toLowerCase());\n}\n\nconst variables = {\n    HOST: getEnv('HOST'),\n    PLAUSIBLE_HOST: getEnv('PLAUSIBLE_HOST'),\n    PLAUSIBLE_ENABLED: getEnv('HOST') && getEnv('PLAUSIBLE_HOST'),\n    DEFAULT_API: getEnv('DEFAULT_API'),\n    ENABLE_WEBCODECS: getEnvBool('ENABLE_WEBCODECS'),\n    ENABLE_DEPRECATED_YOUTUBE_HLS: getEnvBool('ENABLE_DEPRECATED_YOUTUBE_HLS'),\n}\n\nconst contacts = {\n    discord: \"https://discord.gg/pQPt8HBUPu\",\n    twitter: \"https://x.com/justusecobalt\",\n    github: \"https://github.com/imputnet/cobalt\",\n    bluesky: \"https://bsky.app/profile/cobalt.tools\",\n    telegram_ru: \"https://t.me/justusecobalt_ru\",\n}\n\nconst partners = {\n    royalehosting: \"https://royalehosting.net/?partner=cobalt\",\n}\n\nconst donate = {\n    stripe: \"https://donate.stripe.com/3cs2cc6ew1Qda4wbII\",\n    liberapay: \"https://liberapay.com/imput/donate\",\n    crypto: {\n        ethereum: \"0xDA47A671B2411468E8320916C3e57D2F60FE7197\",\n        monero: \"463y93PsQDTYGVPAHUNcjiYDsxWjn7bL2FS9GYXjetEH5XEoNKB7kCHHQXsuoebbSv8RqGspo61pxhMQQrudDky2AfTGbs3\",\n        solana: \"BWPQpPvSyfauUm1BwmV55qE1vJT56Pc6qHrNFzCmtmFJ\",\n        litecoin: \"ltc1qfdemqtfsj7pgnfmtv7n5agtrh0yzwk2pzgr96y\",\n        bitcoin: \"bc1qeqd27qknt3fwvuzpvv2ne730klggggwcqm43yq\",\n        ton: \"UQBosUGIkvZcV8k02bdm-lRFLXrlr1A_sdO1FnXhAsUOLx1S\",\n    },\n    other: {\n        boosty: \"https://boosty.to/wukko/donate\",\n    }\n};\n\nconst siriShortcuts = {\n    photos: \"https://www.icloud.com/shortcuts/14e9aebf04b24156acc34ceccf7e6fcd\",\n    files: \"https://www.icloud.com/shortcuts/2134cd9d4d6b41448b2201f933542b2e\",\n};\n\nconst docs = {\n    instanceHosting: \"https://github.com/imputnet/cobalt/blob/main/docs/run-an-instance.md\",\n    webLicense: \"https://github.com/imputnet/cobalt/blob/main/web/LICENSE\",\n    apiLicense: \"https://github.com/imputnet/cobalt/blob/main/api/LICENSE\",\n};\n\nconst officialApiURL = \"https://api.cobalt.tools\";\n\nexport { donate, officialApiURL, contacts, partners, siriShortcuts, docs };\nexport default variables;\n"
  },
  {
    "path": "web/src/lib/haptics.ts",
    "content": "import { get } from \"svelte/store\";\nimport { device } from \"$lib/device\";\nimport settings from \"$lib/state/settings\";\n\nconst canUseHaptics = () => {\n    return device.supports.haptics && !get(settings).accessibility.disableHaptics;\n}\n\nexport const hapticSwitch = () => {\n    if (!canUseHaptics()) return;\n\n    try {\n        const label = document.createElement(\"label\");\n        label.ariaHidden = \"true\";\n        label.style.display = \"none\";\n\n        const input = document.createElement(\"input\");\n        input.type = \"checkbox\";\n        input.setAttribute(\"switch\", \"\");\n        label.appendChild(input);\n\n        document.head.appendChild(label);\n        label.click();\n        document.head.removeChild(label);\n    } catch {\n        // ignore\n    }\n}\n\nexport const hapticConfirm = () => {\n    if (!canUseHaptics()) return;\n\n    hapticSwitch();\n    setTimeout(() => hapticSwitch(), 120);\n}\n\nexport const hapticError = () => {\n    if (!canUseHaptics()) return;\n\n    hapticSwitch();\n    setTimeout(() => hapticSwitch(), 120);\n    setTimeout(() => hapticSwitch(), 240);\n}\n"
  },
  {
    "path": "web/src/lib/i18n/locale.ts",
    "content": "import { derived } from 'svelte/store';\n\nimport languages from '$i18n/languages.json';\n\nimport settings from '$lib/state/settings';\nimport { device } from '$lib/device';\nimport { INTERNAL_locale, defaultLocale } from '$lib/i18n/translations';\n\nconst isValid = (lang: string) => (\n    Object.keys(languages).includes(lang)\n);\n\nexport default derived(\n    settings,\n    ($settings) => {\n        let currentLocale = defaultLocale;\n\n        if ($settings.appearance.autoLanguage) {\n            if (isValid(device.prefers.language)) {\n                currentLocale = device.prefers.language;\n            }\n        } else {\n            if (isValid($settings.appearance.language)) {\n                currentLocale = $settings.appearance.language;\n            }\n        }\n\n        INTERNAL_locale.set(currentLocale);\n        return currentLocale;\n    }\n);\n"
  },
  {
    "path": "web/src/lib/i18n/translations.ts",
    "content": "import i18n from 'sveltekit-i18n';\n\nimport type { Config } from 'sveltekit-i18n';\nimport type {\n    GenericImport,\n    StructuredLocfileInfo,\n    LocalizationContent\n} from '$lib/types/i18n';\n\nimport _languages from '$i18n/languages.json';\n\nconst locFiles = import.meta.glob('$i18n/*/**/*.json');\nconst parsedLocfiles: StructuredLocfileInfo = {};\n\nfor (const [path, loader] of Object.entries(locFiles)) {\n    const [, , lang, ...keyComponents] = path.split('/');\n    const key = keyComponents.map(k => k.replace('.json', '')).join('.');\n    parsedLocfiles[lang] = {\n        ...parsedLocfiles[lang],\n        [key]: loader as GenericImport\n    };\n}\n\nconst defaultLocale = 'en';\nconst languages: Record<string, string> = _languages;\n\nconst config: Config<{\n    value?: string;\n    formats?: string;\n    limit?: number;\n    service?: string;\n}> = {\n    fallbackLocale: defaultLocale,\n    translations: Object.keys(parsedLocfiles).reduce((obj, lang) => {\n        languages[lang] ??= `${lang} (missing name)`;\n\n        return {\n            ...obj,\n            [lang]: { languages }\n        }\n    }, {}),\n    loaders: Object.entries(parsedLocfiles).map(([lang, keys]) => {\n        return Object.entries(keys).map(([key, importer]) => {\n            return {\n                locale: lang,\n                key,\n                loader: () => importer().then(\n                    l => l.default as LocalizationContent\n                )\n            }\n        });\n    }).flat()\n};\n\nexport { defaultLocale };\nexport const {\n    t, loading, locales, locale: INTERNAL_locale, translations,\n    loadTranslations, addTranslations, setLocale, setRoute\n} = new i18n(config);\n"
  },
  {
    "path": "web/src/lib/libav.ts",
    "content": "import * as Storage from \"$lib/storage\";\nimport LibAV, { type LibAV as LibAVInstance } from \"@imput/libav.js-remux-cli\";\nimport EncodeLibAV from \"@imput/libav.js-encode-cli\";\n\nimport type { FfprobeData } from \"fluent-ffmpeg\";\nimport type { FFmpegProgressCallback, FFmpegProgressEvent, FFmpegProgressStatus, RenderParams } from \"$lib/types/libav\";\n\nexport default class LibAVWrapper {\n    libav: Promise<LibAVInstance> | null;\n    concurrency: number;\n    onProgress?: FFmpegProgressCallback;\n\n    constructor(onProgress?: FFmpegProgressCallback) {\n        this.libav = null;\n        this.concurrency = Math.min(4, navigator.hardwareConcurrency || 0);\n        this.onProgress = onProgress;\n    }\n\n    init(options?: LibAV.LibAVOpts) {\n        const variant = options?.variant || 'remux';\n        let constructor: typeof LibAV.LibAV;\n\n        if (variant === 'remux') {\n            constructor = LibAV.LibAV;\n        } else if (variant === 'encode') {\n            constructor = EncodeLibAV.LibAV;\n        } else {\n            throw \"invalid variant\";\n        }\n\n        if (this.concurrency && !this.libav) {\n            this.libav = constructor({\n                ...options,\n                variant: undefined,\n                base: '/_libav'\n            });\n        }\n    }\n\n    async terminate() {\n        if (this.libav) {\n            const libav = await this.libav;\n            libav.terminate();\n        }\n    }\n\n    async probe(blob: Blob) {\n        if (!this.libav) throw new Error(\"LibAV wasn't initialized\");\n        const libav = await this.libav;\n\n        await libav.mkreadaheadfile('input', blob);\n\n        try {\n            await libav.ffprobe([\n                '-v', 'quiet',\n                '-print_format', 'json',\n                '-show_format',\n                '-show_streams',\n                'input',\n                '-o', 'output.json'\n            ]);\n\n            const copy = await libav.readFile('output.json');\n            const text = new TextDecoder().decode(copy);\n            await libav.unlink('output.json');\n\n            return JSON.parse(text) as FfprobeData;\n        } finally {\n            await libav.unlinkreadaheadfile('input');\n        }\n    }\n\n    async render({ files, output, args }: RenderParams) {\n        if (!this.libav) throw new Error(\"LibAV wasn't initialized\");\n        const libav = await this.libav;\n\n        if (!(output.format && output.type)) {\n            throw new Error(\"output's format or type is missing\");\n        }\n\n        const outputName = `output.${output.format}`;\n        const ffInputs = [];\n\n        try {\n            for (let i = 0; i < files.length; i++) {\n                const file = files[i];\n\n                await libav.mkreadaheadfile(`input${i}`, file);\n                ffInputs.push('-i', `input${i}`);\n            }\n\n            await libav.mkwriterdev(outputName);\n            await libav.mkwriterdev('progress.txt');\n\n            const totalInputSize = files.reduce((a, b) => a + b.size, 0);\n            const storage = await Storage.init(totalInputSize);\n\n            libav.onwrite = async (name, pos, data) => {\n                if (name === 'progress.txt') {\n                    try {\n                        return this.#emitProgress(data);\n                    } catch (e) {\n                        console.error(e);\n                    }\n                } else if (name !== outputName) return;\n\n                await storage.write(data, pos);\n            };\n\n            await libav.ffmpeg([\n                '-nostdin', '-y',\n                '-loglevel', 'error',\n                '-progress', 'progress.txt',\n                '-threads', this.concurrency.toString(),\n                ...ffInputs,\n                ...args,\n                outputName\n            ]);\n\n            const file = Storage.retype(await storage.res(), output.type);\n            if (file.size === 0) return;\n\n            return file;\n        } finally {\n            try {\n                await libav.unlink(outputName);\n                await libav.unlink('progress.txt');\n\n                await Promise.allSettled(\n                    files.map((_, i) =>\n                        libav.unlinkreadaheadfile(`input${i}`)\n                    ));\n            } catch { /* catch & ignore */ }\n        }\n    }\n\n    #emitProgress(data: Uint8Array | Int8Array) {\n        if (!this.onProgress) return;\n\n        const copy = new Uint8Array(data);\n        const text = new TextDecoder().decode(copy);\n        const entries = Object.fromEntries(\n            text.split('\\n')\n                .filter(a => a)\n                .map(a => a.split('='))\n        );\n\n        const status: FFmpegProgressStatus = (() => {\n            const { progress } = entries;\n\n            if (progress === 'continue' || progress === 'end') {\n                return progress;\n            }\n\n            return \"unknown\";\n        })();\n\n        const tryNumber = (str: string, transform?: (n: number) => number) => {\n            if (str) {\n                const num = Number(str);\n                if (!isNaN(num)) {\n                    if (transform)\n                        return transform(num);\n                    else\n                        return num;\n                }\n            }\n        }\n\n        const progress: FFmpegProgressEvent = {\n            status,\n            frame: tryNumber(entries.frame),\n            fps: tryNumber(entries.fps),\n            total_size: tryNumber(entries.total_size),\n            dup_frames: tryNumber(entries.dup_frames),\n            drop_frames: tryNumber(entries.drop_frames),\n            speed: tryNumber(entries.speed?.trim()?.replace('x', '')),\n            out_time_sec: tryNumber(entries.out_time_us, n => Math.floor(n / 1e6))\n        };\n\n        this.onProgress(progress);\n    }\n}\n"
  },
  {
    "path": "web/src/lib/polyfills/abortsignal-timeout.ts",
    "content": "import { browser } from \"$app/environment\";\n\nif (browser && 'AbortSignal' in window && !window.AbortSignal.timeout) {\n    window.AbortSignal.timeout = (milliseconds: number) => {\n        const controller = new AbortController();\n        setTimeout(() => controller.abort(\"timed out\"), milliseconds);\n\n        return controller.signal;\n    }\n}\n"
  },
  {
    "path": "web/src/lib/polyfills/user-activation.ts",
    "content": "import { browser } from \"$app/environment\";\nimport type { Writeable } from \"$lib/types/generic\";\n\nif (browser && !navigator.userActivation) {\n    const TRANSIENT_TIMEOUT = navigator.userAgent.includes('Firefox') ? 5000 : 2000;\n    let _timeout: number | undefined;\n\n    const userActivation: Writeable<UserActivation> = {\n        isActive: false,\n        hasBeenActive: false\n    };\n\n    const receiveEvent = (e: Event) => {\n        // An activation triggering input event is any event whose isTrusted attribute is true [...]\n        if (!e.isTrusted) return;\n\n        // and whose type is one of:\n        if (e instanceof PointerEvent) {\n            if (\n                // \"pointerdown\", provided the event's pointerType is \"mouse\";\n                (e.type === 'pointerdown' && e.pointerType !== 'mouse')\n                // \"pointerup\", provided the event's pointerType is not \"mouse\";\n                || (e.type === 'pointerup' && e.pointerType === 'mouse')\n            )\n                return;\n        } else if (e instanceof KeyboardEvent) {\n            // \"keydown\", provided the key is neither the Esc key nor a shortcut key\n            // reserved by the user agent;\n            if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey)\n                return;\n\n            // the handling for this is a bit more complex,\n            // but this is fine for our use case\n            if (e.key !== 'Return' && e.key !== 'Enter' && e.key.length > 1)\n                return;\n        }\n\n        userActivation.hasBeenActive = true;\n        userActivation.isActive = true;\n\n        clearTimeout(_timeout);\n        _timeout = window.setTimeout(() => {\n            userActivation.isActive = false;\n            _timeout = undefined;\n        }, TRANSIENT_TIMEOUT);\n    }\n\n    // https://html.spec.whatwg.org/multipage/interaction.html#the-useractivation-interface\n    for (const event of [ 'keydown', 'mousedown', 'pointerdown', 'pointerup', 'touchend' ]) {\n        window.addEventListener(event, receiveEvent);\n    }\n\n    (navigator.userActivation as UserActivation) = userActivation;\n}\n"
  },
  {
    "path": "web/src/lib/polyfills.ts",
    "content": "import \"./polyfills/user-activation\";\nimport \"./polyfills/abortsignal-timeout\";\n"
  },
  {
    "path": "web/src/lib/settings/audio-sub-language.ts",
    "content": "import { t as translation } from \"$lib/i18n/translations\";\nimport type { FromReadable } from \"$lib/types/generic\";\n\nconst languages = [\n    // most popular languages are first, according to\n    // https://en.wikipedia.org/wiki/List_of_languages_by_number_of_native_speakers\n    \"en\", \"es\", \"pt\", \"fr\", \"ru\",\n    \"zh\", \"vi\", \"hi\", \"bn\", \"ja\",\n\n    \"af\", \"am\", \"ar\", \"as\", \"az\",\n    \"be\", \"bg\", \"bs\", \"ca\", \"cs\",\n    \"da\", \"de\", \"el\", \"et\", \"eu\",\n    \"fa\", \"fi\", \"fil\", \"gl\", \"gu\",\n    \"hr\", \"hu\", \"hy\", \"id\", \"is\",\n    \"it\", \"iw\", \"ka\", \"kk\", \"ko\",\n    \"km\", \"kn\", \"ky\", \"lo\", \"lt\",\n    \"lv\", \"mk\", \"ml\", \"mn\", \"mr\",\n    \"ms\", \"my\", \"no\", \"ne\", \"nl\",\n    \"or\", \"pa\", \"pl\", \"ro\", \"si\",\n    \"sk\", \"sl\", \"sq\", \"sr\", \"sv\",\n    \"sw\", \"ta\", \"te\", \"th\", \"tr\",\n    \"uk\", \"ur\", \"uz\", \"zh-Hans\",\n    \"zh-Hant\", \"zh-CN\", \"zh-HK\",\n    \"zh-TW\", \"zu\"\n];\n\nexport const youtubeDubLanguages = [\"original\", ...languages] as const;\nexport const subtitleLanguages = [\"none\", ...languages] as const;\n\nexport type YoutubeDubLang = typeof youtubeDubLanguages[number];\nexport type SubtitleLang = typeof subtitleLanguages[number];\n\ntype TranslationFunction = FromReadable<typeof translation>;\n\nconst namedLanguages = (\n    languages: typeof youtubeDubLanguages | typeof subtitleLanguages,\n    t: TranslationFunction,\n) => {\n    return languages.reduce((obj, lang) => {\n        let name: string;\n\n        switch (lang) {\n            case \"original\":\n                name = t(\"settings.youtube.dub.original\");\n                break;\n            case \"none\":\n                name = t(\"settings.subtitles.none\");\n                break;\n            default: {\n                let intlName;\n                try {\n                    intlName = new Intl.DisplayNames([lang], { type: 'language' }).of(lang);\n                } catch { /* */ };\n                name = `${intlName || \"unknown\"} (${lang})`;\n                break;\n            }\n        }\n\n        return {\n            ...obj,\n            [lang]: name,\n        };\n    }, {}) as Record<typeof languages[number], string>;\n}\n\nexport const namedYoutubeDubLanguages = (t: TranslationFunction) => {\n    return namedLanguages(youtubeDubLanguages, t);\n}\n\nexport const namedSubtitleLanguages = (t: TranslationFunction) => {\n    return namedLanguages(subtitleLanguages, t);\n}\n\nexport const getBrowserLanguage = (): YoutubeDubLang => {\n    if (typeof navigator !== 'undefined') {\n        const browserLanguage = navigator.language as YoutubeDubLang;\n        if (youtubeDubLanguages.includes(browserLanguage)) {\n            return browserLanguage;\n        }\n        const shortened = browserLanguage.split('-')[0] as YoutubeDubLang;\n        if (youtubeDubLanguages.includes(shortened)) {\n            return shortened;\n        }\n    }\n    return \"original\";\n}\n"
  },
  {
    "path": "web/src/lib/settings/defaults.ts",
    "content": "import { device } from \"$lib/device\";\nimport { defaultLocale } from \"$lib/i18n/translations\";\nimport type { CobaltSettings } from \"$lib/types/settings\";\n\nconst defaultSettings: CobaltSettings = {\n    schemaVersion: 6,\n    advanced: {\n        debug: false,\n        useWebCodecs: false,\n    },\n    appearance: {\n        theme: \"auto\",\n        language: defaultLocale,\n        autoLanguage: true,\n        hideRemuxTab: false,\n    },\n    accessibility: {\n        reduceMotion: false,\n        reduceTransparency: false,\n        disableHaptics: false,\n        dontAutoOpenQueue: false,\n    },\n    save: {\n        alwaysProxy: false,\n        localProcessing:\n            device.supports.defaultLocalProcessing ? \"preferred\" : \"disabled\",\n        audioBitrate: \"128\",\n        audioFormat: \"mp3\",\n        disableMetadata: false,\n        downloadMode: \"auto\",\n        filenameStyle: \"basic\",\n        savingMethod: \"download\",\n        allowH265: false,\n        tiktokFullAudio: false,\n        convertGif: true,\n        videoQuality: \"1080\",\n        subtitleLang: \"none\",\n        youtubeVideoCodec: \"h264\",\n        youtubeVideoContainer: \"auto\",\n        youtubeDubLang: \"original\",\n        youtubeHLS: false,\n        youtubeBetterAudio: false,\n    },\n    privacy: {\n        disableAnalytics: false,\n    },\n    processing: {\n        customInstanceURL: \"\",\n        customApiKey: \"\",\n        enableCustomInstances: false,\n        enableCustomApiKey: false,\n        seenCustomWarning: false,\n    }\n}\n\nexport default defaultSettings;\n"
  },
  {
    "path": "web/src/lib/settings/lazy-get.ts",
    "content": "import defaults from \"$lib/settings/defaults\";\nimport type { CobaltSettings } from \"$lib/types/settings\";\n\nexport default function lazySettingGetter(settings: CobaltSettings) {\n    // Returns the setting value only if it differs from the default.\n    return <\n        Context extends Exclude<keyof CobaltSettings, 'schemaVersion'>,\n        Id extends keyof CobaltSettings[Context]\n    >(context: Context, key: Id) => {\n        if (defaults[context][key] !== settings[context][key]) {\n            return settings[context][key];\n        }\n    }\n}\n"
  },
  {
    "path": "web/src/lib/settings/migrate-v7.ts",
    "content": "import type { AllPartialSettingsWithSchema } from \"$lib/types/settings\";\n\nconst oldSwitcherValues = {\n    theme: ['auto', 'light', 'dark'],\n    vCodec: ['h264', 'av1', 'vp9'],\n    vQuality: ['720', 'max', '2160', '1440', '1080', '480', '360', '240', '144'],\n    aFormat: ['mp3', 'best', 'ogg', 'wav', 'opus'],\n    filenamePattern: ['classic', 'pretty', 'basic', 'nerdy']\n} as const;\n\nconst oldCheckboxes = [\n    'audioMode',\n    'fullTikTokAudio',\n    'muteAudio',\n    'reduceTransparency',\n    'disableAnimations',\n    'disableMetadata',\n    'plausible_ignore',\n    'ytDub',\n    'tiktokH265'\n] as const;\n\ntype LegacySwitchers = keyof typeof oldSwitcherValues;\ntype LegacyCheckboxes = typeof oldCheckboxes[number];\n\nconst _get = (name: LegacyCheckboxes | LegacySwitchers) => {\n    const value = localStorage.getItem(name);\n    if (value !== null) {\n        return value;\n    }\n}\n\nconst getBool = (name: LegacyCheckboxes) => {\n    const value = _get(name);\n\n    if (value !== undefined) {\n        return value === 'true';\n    }\n}\n\nconst getLiteral = <T extends LegacySwitchers>(name: T) => {\n    const value = _get(name);\n    if (value === undefined) {\n        return;\n    }\n\n    const values = oldSwitcherValues[name] as readonly string[];\n    if (values.includes(value)) {\n        type SwitcherOptions = typeof oldSwitcherValues[T][number];\n        return value as SwitcherOptions;\n    }\n}\n\nconst getDownloadMode = () => {\n    if (getBool('muteAudio')) {\n        return 'mute';\n    }\n\n    if (getBool('audioMode')) {\n        return 'audio';\n    }\n\n    return 'auto';\n}\n\nconst cleanup = () => {\n    for (const key of Object.keys(localStorage)) {\n        // plausible script needs this value, so we keep it if migrating\n        if (key !== 'plausible_ignore') {\n            localStorage.removeItem(key);\n        }\n    }\n}\n\nexport const migrateOldSettings = () => {\n    if (getLiteral('vCodec') === undefined) {\n        /* on the old frontend, preferences such as \"vCodec\" are set right\n         * when you open it. so, if this preference does not exist, we can\n         * assume that the user never used the old frontend, and abort the\n         * migration early. */\n        return;\n    }\n\n    const migrated: AllPartialSettingsWithSchema = {\n        schemaVersion: 2,\n        appearance: {\n            theme: getLiteral('theme'),\n            reduceTransparency: getBool('reduceTransparency'),\n            reduceMotion: getBool('disableAnimations'),\n        },\n        privacy: {\n            disableAnalytics: getBool('plausible_ignore')\n        },\n        save: {\n            youtubeVideoCodec: getLiteral('vCodec'),\n            videoQuality: getLiteral('vQuality'),\n            audioFormat: getLiteral('aFormat'),\n            downloadMode: getDownloadMode(),\n            filenameStyle: getLiteral('filenamePattern'),\n            tiktokFullAudio: getBool('fullTikTokAudio'),\n            tiktokH265: getBool('tiktokH265'),\n            disableMetadata: getBool('disableMetadata'),\n            youtubeDubBrowserLang: getBool('ytDub'),\n        }\n    };\n\n    cleanup();\n    return migrated;\n}\n"
  },
  {
    "path": "web/src/lib/settings/migrate.ts",
    "content": "import type { RecursivePartial } from \"$lib/types/generic\";\nimport type {\n    PartialSettings,\n    AllPartialSettingsWithSchema,\n    CobaltSettingsV3,\n    CobaltSettingsV4,\n    CobaltSettingsV5,\n    CobaltSettingsV6,\n} from \"$lib/types/settings\";\nimport { getBrowserLanguage } from \"$lib/settings/audio-sub-language\";\n\ntype Migrator = (s: AllPartialSettingsWithSchema) => AllPartialSettingsWithSchema;\n\nconst migrations: Record<number, Migrator> = {\n    [3]: (settings: AllPartialSettingsWithSchema) => {\n        const out = settings as RecursivePartial<CobaltSettingsV3>;\n        out.schemaVersion = 3;\n\n        if (settings?.save && \"youtubeDubBrowserLang\" in settings.save) {\n            if (settings.save.youtubeDubBrowserLang) {\n                out.save!.youtubeDubLang = getBrowserLanguage();\n            }\n\n            delete settings.save.youtubeDubBrowserLang;\n        }\n\n        return out as AllPartialSettingsWithSchema;\n    },\n\n    [4]: (settings: AllPartialSettingsWithSchema) => {\n        const out = settings as RecursivePartial<CobaltSettingsV4>;\n        out.schemaVersion = 4;\n\n        if (settings?.processing) {\n            if (\"allowDefaultOverride\" in settings.processing) {\n                delete settings.processing.allowDefaultOverride;\n            }\n            if (\"seenOverrideWarning\" in settings.processing) {\n                delete settings.processing.seenOverrideWarning;\n            }\n        }\n\n        return out as AllPartialSettingsWithSchema;\n    },\n\n    [5]: (settings: AllPartialSettingsWithSchema) => {\n        const out = settings as RecursivePartial<CobaltSettingsV5>;\n        out.schemaVersion = 5;\n\n        if (settings?.save) {\n            if (\"tiktokH265\" in settings.save) {\n                out.save!.allowH265 = settings.save.tiktokH265;\n                delete settings.save.tiktokH265;\n            }\n            if (\"twitterGif\" in settings.save) {\n                out.save!.convertGif = settings.save.twitterGif;\n                delete settings.save.twitterGif;\n            }\n        }\n\n        if (settings?.privacy) {\n            if (\"alwaysProxy\" in settings.privacy) {\n                out.save ??= {};\n                out.save.alwaysProxy = settings.privacy.alwaysProxy;\n                delete settings.privacy.alwaysProxy;\n            }\n        }\n\n        if (settings?.appearance) {\n            if (\"reduceMotion\" in settings.appearance) {\n                out.accessibility ??= {};\n                out.accessibility.reduceMotion = settings.appearance.reduceMotion;\n                delete settings.appearance.reduceMotion;\n            }\n            if (\"reduceTransparency\" in settings.appearance) {\n                out.accessibility ??= {};\n                out.accessibility.reduceTransparency = settings.appearance.reduceTransparency;\n                delete settings.appearance.reduceTransparency;\n            }\n        }\n\n        return out as AllPartialSettingsWithSchema;\n    },\n\n    [6]: (settings: AllPartialSettingsWithSchema) => {\n        const out = settings as RecursivePartial<CobaltSettingsV6>;\n        out.schemaVersion = 6;\n\n        if (settings?.save) {\n            if (\"localProcessing\" in settings.save) {\n                out.save!.localProcessing =\n                    settings.save.localProcessing ? \"preferred\" : \"disabled\";\n            }\n        }\n\n        return out as AllPartialSettingsWithSchema;\n    },\n};\n\nexport const migrate = (settings: AllPartialSettingsWithSchema): PartialSettings => {\n    return Object.keys(migrations)\n        .map(Number)\n        .filter((version) => version > settings.schemaVersion)\n        .reduce((settings, migrationVersion) => {\n            return migrations[migrationVersion](settings);\n        }, settings as AllPartialSettingsWithSchema) as PartialSettings;\n};\n"
  },
  {
    "path": "web/src/lib/settings/validate.ts",
    "content": "import type { Optional } from '$lib/types/generic';\nimport defaultSettings from '$lib/settings/defaults';\nimport {\n    downloadModeOptions,\n    filenameStyleOptions,\n    savingMethodOptions,\n    themeOptions,\n    videoQualityOptions,\n    youtubeVideoCodecOptions,\n    type PartialSettings,\n} from '$lib/types/settings';\nimport { youtubeDubLanguages } from '$lib/settings/audio-sub-language';\n\nfunction validateTypes(input: unknown, reference = defaultSettings as unknown) {\n    if (typeof input === 'undefined')\n        return true;\n\n    if (typeof input !== typeof reference)\n        return false;\n\n    if (typeof reference !== 'object')\n        return true;\n\n    if (reference === null || input === null)\n        return input === reference;\n\n    if (Array.isArray(reference)) {\n        // TODO: we dont expect the reference array to hold any\n        //       elements, but we should at maybe check whether\n        //       the input array types are all matching.\n        return true;\n    }\n\n    // we know that `input` is an `object` based on the first\n    // two `if`s, but for some reason typescript doesn't.  :)\n    if (typeof input !== 'object')\n        return false;\n\n    const keys = new Set([\n        ...Object.keys(input),\n        ...Object.keys(reference)\n    ]);\n\n    for (const key of keys) {\n        const _input = input as Record<string, unknown>;\n        const _reference = reference as Record<string, unknown>;\n\n        if (!validateTypes(_input[key], _reference[key])) {\n            return false;\n        }\n    }\n\n    return true;\n}\n\nfunction validateLiteral(value: Optional<string>, allowed: readonly string[]) {\n    return value === undefined || allowed.includes(value);\n}\n\nfunction validateLiterals(literals: [Optional<string>, readonly string[]][]) {\n    for (const [ value, allowed ] of literals) {\n        if (!validateLiteral(value, allowed))\n            return false;\n    }\n\n    return true;\n}\n\n// performs a basic check on an \"untrusted\" settings object.\nexport function validateSettings(settings: PartialSettings) {\n    if (!settings?.schemaVersion) {\n        return false;\n    }\n\n    return (\n        validateTypes(settings)\n        && validateLiterals([\n            [ settings?.appearance?.theme      , themeOptions ],\n            [ settings?.save?.downloadMode     , downloadModeOptions ],\n            [ settings?.save?.filenameStyle    , filenameStyleOptions ],\n            [ settings?.save?.videoQuality     , videoQualityOptions ],\n            [ settings?.save?.youtubeVideoCodec, youtubeVideoCodecOptions ],\n            [ settings?.save?.savingMethod     , savingMethodOptions ],\n            [ settings?.save?.youtubeDubLang   , youtubeDubLanguages ]\n        ])\n    );\n}\n"
  },
  {
    "path": "web/src/lib/state/dialogs.ts",
    "content": "import { readable, type Updater } from \"svelte/store\";\nimport type { DialogInfo } from \"$lib/types/dialog\";\n\nlet update: (_: Updater<DialogInfo[]>) => void;\n\nexport default readable<DialogInfo[]>(\n    [],\n    (_, _update) => { update = _update }\n);\n\nexport function createDialog(newData: DialogInfo) {\n    update((popups) => {\n        popups.push(newData);\n        return popups;\n    });\n}\n\nexport function killDialog() {\n    update((popups) => {\n        popups.pop()\n        return popups;\n    });\n}\n"
  },
  {
    "path": "web/src/lib/state/omnibox.ts",
    "content": "import { writable } from \"svelte/store\";\nimport type { CobaltDownloadButtonState } from \"$lib/types/omnibox\";\n\nexport const link = writable(\"\");\nexport const downloadButtonState = writable<CobaltDownloadButtonState>(\"idle\");\n"
  },
  {
    "path": "web/src/lib/state/queue-visibility.ts",
    "content": "import settings from \"$lib/state/settings\";\nimport { get, writable } from \"svelte/store\";\n\nexport const queueVisible = writable(false);\n\nexport const openQueuePopover = () => {\n    const visible = get(queueVisible);\n    if (!visible && !get(settings).accessibility.dontAutoOpenQueue) {\n        return queueVisible.update(v => !v);\n    }\n}\n"
  },
  {
    "path": "web/src/lib/state/server-info.ts",
    "content": "import { writable } from \"svelte/store\";\nimport * as ServerInfo from \"$lib/api/server-info\";\n\nexport default writable<ServerInfo.CobaltServerInfoCache | undefined>();\n"
  },
  {
    "path": "web/src/lib/state/settings.ts",
    "content": "import { derived, readable, type Updater } from 'svelte/store';\nimport { browser } from '$app/environment';\nimport { merge } from 'ts-deepmerge';\nimport type {\n    PartialSettings,\n    AllPartialSettingsWithSchema,\n    CobaltSettings\n} from '../types/settings';\nimport { migrateOldSettings } from '../settings/migrate-v7';\nimport defaultSettings from '../settings/defaults';\nimport { migrate } from '$lib/settings/migrate';\n\nconst updatePlausiblePreference = (settings: PartialSettings) => {\n    if (settings.privacy?.disableAnalytics) {\n        localStorage.setItem('plausible_ignore', 'true');\n    } else if (localStorage.getItem('plausible_ignore') !== null) {\n        localStorage.removeItem('plausible_ignore');\n    }\n}\n\nconst writeToStorage = (settings: PartialSettings) => {\n    localStorage.setItem(\n        \"settings\",\n        JSON.stringify(settings)\n    );\n\n    return settings;\n}\n\nconst loadFromStorage = () => {\n    if (!browser)\n        return {};\n\n    const settings = localStorage.getItem('settings');\n    if (!settings) {\n        const migrated = migrateOldSettings();\n        if (migrated) {\n            return writeToStorage(migrate(migrated));\n        }\n\n        return {};\n    }\n\n    return loadFromString(settings);\n}\n\nexport const loadFromString = (settings: string): PartialSettings => {\n    const parsed = JSON.parse(settings) as AllPartialSettingsWithSchema;\n    if (parsed.schemaVersion < defaultSettings.schemaVersion) {\n        return writeToStorage(migrate(parsed));\n    }\n\n    return parsed as PartialSettings;\n}\n\nlet update: (_: Updater<PartialSettings>) => void;\n\n// deep merge partial type into full CobaltSettings type\nconst mergeWithDefaults = (partial: PartialSettings) => {\n    return merge(defaultSettings, partial) as CobaltSettings;\n}\n\nexport const storedSettings = readable<PartialSettings>(\n    loadFromStorage(),\n    (_, _update) => { update = _update }\n);\n\n// update settings from outside\nexport function updateSetting(partial: PartialSettings) {\n    update((current) => {\n        const updated = writeToStorage(\n            merge(\n                current,\n                partial,\n                { schemaVersion: defaultSettings.schemaVersion }\n            )\n        );\n\n        updatePlausiblePreference(partial);\n        return updated;\n    });\n}\n\nexport function resetSettings() {\n    update(() => {\n        localStorage.removeItem('settings');\n        return {};\n    });\n}\n\nexport default derived(\n    storedSettings,\n    $settings => mergeWithDefaults($settings)\n);\n"
  },
  {
    "path": "web/src/lib/state/task-manager/current-tasks.ts",
    "content": "import { readonly, writable } from \"svelte/store\";\n\nimport type { CobaltWorkerProgress } from \"$lib/types/workers\";\nimport type { CobaltCurrentTasks, CobaltCurrentTaskItem } from \"$lib/types/task-manager\";\n\nconst currentTasks_ = writable<CobaltCurrentTasks>({});\nexport const currentTasks = readonly(currentTasks_);\n\nexport function addWorkerToQueue(workerId: string, item: CobaltCurrentTaskItem) {\n    currentTasks_.update(tasks => {\n        tasks[workerId] = item;\n        return tasks;\n    });\n}\n\nexport function removeWorkerFromQueue(id: string) {\n    currentTasks_.update(tasks => {\n        delete tasks[id];\n        return tasks;\n    });\n}\n\nexport function updateWorkerProgress(workerId: string, progress: CobaltWorkerProgress) {\n    currentTasks_.update(allTasks => {\n        allTasks[workerId].progress = progress;\n        return allTasks;\n    });\n}\n\nexport function clearCurrentTasks() {\n    currentTasks_.set({});\n}\n"
  },
  {
    "path": "web/src/lib/state/task-manager/queue.ts",
    "content": "import { readable, type Updater } from \"svelte/store\";\n\nimport { schedule } from \"$lib/task-manager/scheduler\";\nimport { clearFileStorage, removeFromFileStorage } from \"$lib/storage/opfs\";\nimport { clearCurrentTasks, removeWorkerFromQueue } from \"$lib/state/task-manager/current-tasks\";\n\nimport type { CobaltQueue, CobaltQueueItem, CobaltQueueItemRunning, UUID } from \"$lib/types/queue\";\n\nconst clearPipelineCache = (queueItem: CobaltQueueItem) => {\n    if (queueItem.state === \"running\") {\n        for (const [ workerId, item ] of Object.entries(queueItem.pipelineResults)) {\n            removeFromFileStorage(item.name);\n            delete queueItem.pipelineResults[workerId];\n        }\n    } else if (queueItem.state === \"done\") {\n        removeFromFileStorage(queueItem.resultFile.name);\n    }\n\n    return queueItem;\n}\n\nlet update: (_: Updater<CobaltQueue>) => void;\n\nexport const queue = readable<CobaltQueue>(\n    {},\n    (_, _update) => { update = _update }\n);\n\nexport function addItem(item: CobaltQueueItem) {\n    update(queueData => {\n        queueData[item.id] = item;\n        return queueData;\n    });\n\n    schedule();\n}\n\nexport function itemError(id: UUID, workerId: UUID, error: string) {\n    update(queueData => {\n        if (queueData[id]) {\n            queueData[id] = clearPipelineCache(queueData[id]);\n\n            queueData[id] = {\n                ...queueData[id],\n                state: \"error\",\n                errorCode: error,\n            }\n        }\n        return queueData;\n    });\n\n    removeWorkerFromQueue(workerId);\n    schedule();\n}\n\nexport function itemDone(id: UUID, file: File) {\n    update(queueData => {\n        if (queueData[id]) {\n            queueData[id] = clearPipelineCache(queueData[id]);\n\n            queueData[id] = {\n                ...queueData[id],\n                state: \"done\",\n                resultFile: file,\n            }\n        }\n        return queueData;\n    });\n\n    schedule();\n}\n\nexport function pipelineTaskDone(id: UUID, workerId: UUID, file: File) {\n    update(queueData => {\n        const item = queueData[id];\n\n        if (item && item.state === 'running') {\n            item.pipelineResults[workerId] = file;\n        }\n\n        return queueData;\n    });\n\n    removeWorkerFromQueue(workerId);\n    schedule();\n}\n\nexport function itemRunning(id: UUID) {\n    update(queueData => {\n        const data = queueData[id] as CobaltQueueItemRunning;\n\n        if (data) {\n            data.state = 'running';\n            data.pipelineResults ??= {};\n        }\n\n        return queueData;\n    });\n\n    schedule();\n}\n\nexport function removeItem(id: UUID) {\n    update(queueData => {\n        const item = queueData[id];\n\n        for (const worker of item.pipeline) {\n            removeWorkerFromQueue(worker.workerId);\n        }\n        clearPipelineCache(item);\n\n        delete queueData[id];\n        return queueData;\n    });\n\n    schedule();\n}\n\nexport function clearQueue() {\n    update(() => ({}));\n    clearCurrentTasks();\n    clearFileStorage();\n}\n"
  },
  {
    "path": "web/src/lib/state/theme.ts",
    "content": "import { readable, derived, type Readable } from 'svelte/store';\nimport { browser } from '$app/environment';\n\nimport settings from '$lib/state/settings';\nimport { themeOptions } from '$lib/types/settings';\n\ntype Theme = typeof themeOptions[number];\n\nlet set: (_: Theme) => void;\n\nconst browserPreference = () => {\n    if (!browser || window.matchMedia('(prefers-color-scheme: light)').matches) {\n        return 'light';\n    }\n\n    return 'dark'\n}\n\nconst browserPreferenceReadable = readable(\n    browserPreference(),\n    _set => { set = _set }\n)\n\nif (browser) {\n    const matchMedia = window.matchMedia('(prefers-color-scheme: dark)');\n\n    if (matchMedia.addEventListener) {\n        matchMedia.addEventListener('change', () => set(browserPreference()));\n    }\n}\n\nexport default derived(\n    [settings, browserPreferenceReadable],\n    ([$settings, $browserPref]) => {\n        if ($settings.appearance.theme !== 'auto') {\n            return $settings.appearance.theme;\n        }\n\n        return $browserPref;\n    },\n    browserPreference()\n) as Readable<Exclude<Theme, \"auto\">>\n\nexport const statusBarColors = {\n    mobile: {\n        dark: \"#000000\",\n        light: \"#ffffff\"\n    },\n    desktop: {\n        dark: \"#131313\",\n        light: \"#f4f4f4\"\n    }\n}\n"
  },
  {
    "path": "web/src/lib/state/turnstile.ts",
    "content": "import settings from \"$lib/state/settings\";\nimport cachedInfo from \"$lib/state/server-info\";\nimport { derived, writable } from \"svelte/store\";\n\nexport const turnstileSolved = writable(false);\nexport const turnstileCreated = writable(false);\n\nexport const turnstileEnabled = derived(\n    [settings, cachedInfo],\n    ([$settings, $cachedInfo]) => {\n        return !!$cachedInfo?.info?.cobalt?.turnstileSitekey &&\n            !(\n                $settings.processing.enableCustomApiKey &&\n                $settings.processing.customApiKey.length > 0\n            )\n    }\n)\n"
  },
  {
    "path": "web/src/lib/storage/index.ts",
    "content": "import type { AbstractStorage } from \"./storage\";\nimport { MemoryStorage } from \"./memory\";\nimport { OPFSStorage } from \"./opfs\";\n\nexport async function init(expectedSize?: number): Promise<AbstractStorage> {\n    if (await OPFSStorage.isAvailable()) {\n        return OPFSStorage.init();\n    }\n\n    if (await MemoryStorage.isAvailable()) {\n        return MemoryStorage.init(expectedSize || 0);\n    }\n\n    throw \"no storage method is available\";\n}\n\nexport function retype(file: File, type: string) {\n    return new File([ file ], file.name, { type });\n}\n"
  },
  {
    "path": "web/src/lib/storage/memory.ts",
    "content": "import { AbstractStorage } from \"./storage\";\nimport { uuid } from \"$lib/util\";\n\nexport class MemoryStorage extends AbstractStorage {\n    #chunkSize: number;\n    #actualSize: number = 0;\n    #chunks: Uint8Array[] = [];\n\n    constructor(chunkSize: number) {\n        super();\n        this.#chunkSize = chunkSize;\n    }\n\n    static async init(expectedSize: number) {\n        const MB = 1024 * 1024;\n        const chunkSize = Math.min(512 * MB, expectedSize);\n\n        const storage = new this(chunkSize);\n\n        // since we expect the output file to be roughly the same size\n        // as inputs, preallocate its size for the output\n        for (\n            let toAllocate = expectedSize;\n            toAllocate > 0;\n            toAllocate -= chunkSize\n        ) {\n            storage.#chunks.push(new Uint8Array(chunkSize));\n        }\n\n        return storage;\n    }\n\n    async res() {\n        // if we didn't need as much space as we allocated for some reason,\n        // shrink the buffers so that we don't inflate the file with zeroes\n        const outputView: Uint8Array[] = [];\n\n        for (let i = 0; i < this.#chunks.length; ++i) {\n            outputView.push(\n                this.#chunks[i].subarray(\n                    0,\n                    Math.min(this.#chunkSize, this.#actualSize),\n                ),\n            );\n\n            this.#actualSize -= this.#chunkSize;\n            if (this.#actualSize <= 0) {\n                break;\n            }\n        }\n\n        return new File(outputView, uuid());\n    }\n\n    #expand(size: number) {\n        while (size > this.#chunkSize * this.#chunks.length) {\n            this.#chunks.push(new Uint8Array(this.#chunkSize));\n        }\n    }\n\n    async write(data: Uint8Array | Int8Array, pos: number) {\n        const writeEnd = pos + data.length;\n        this.#expand(writeEnd);\n\n        const chunkIndex = pos / this.#chunkSize | 0;\n        const offset = pos - (this.#chunkSize * chunkIndex);\n\n        if (offset + data.length > this.#chunkSize) {\n            this.#chunks[chunkIndex].set(\n                data.subarray(0, this.#chunkSize - offset),\n                offset,\n            );\n            this.#chunks[chunkIndex + 1].set(\n                data.subarray(this.#chunkSize - offset),\n                0,\n            );\n        } else {\n            this.#chunks[chunkIndex].set(data, offset);\n        }\n\n        this.#actualSize = Math.max(writeEnd, this.#actualSize);\n        return data.length;\n    }\n\n    async destroy() {\n        this.#chunks = [];\n    }\n\n    static async isAvailable() {\n        return true;\n    }\n}\n"
  },
  {
    "path": "web/src/lib/storage/opfs.ts",
    "content": "import { AbstractStorage } from \"./storage\";\nimport { uuid } from \"$lib/util\";\n\nconst COBALT_PROCESSING_DIR = \"cobalt-processing-data\";\n\nexport class OPFSStorage extends AbstractStorage {\n    #root;\n    #handle;\n    #io;\n\n    static #isAvailable?: boolean;\n\n    constructor(root: FileSystemDirectoryHandle, handle: FileSystemFileHandle, reader: FileSystemSyncAccessHandle) {\n        super();\n        this.#root = root;\n        this.#handle = handle;\n        this.#io = reader;\n    }\n\n    static async init() {\n        const root = await navigator.storage.getDirectory();\n        const cobaltDir = await root.getDirectoryHandle(COBALT_PROCESSING_DIR, { create: true });\n        const handle = await cobaltDir.getFileHandle(uuid(), { create: true });\n        const reader = await handle.createSyncAccessHandle();\n\n        return new this(cobaltDir, handle, reader);\n    }\n\n    async res() {\n        // await for compat with ios 15\n        await this.#io.flush();\n        await this.#io.close();\n        return await this.#handle.getFile();\n    }\n\n    async write(data: Uint8Array | Int8Array, offset: number) {\n        return this.#io.write(data, { at: offset })\n    }\n\n    async destroy() {\n        await this.#root.removeEntry(this.#handle.name);\n    }\n\n    static async #computeIsAvailable() {\n        let tempFile = uuid(), ok = true;\n\n        if (typeof navigator === 'undefined')\n            return false;\n\n        if ('storage' in navigator && 'getDirectory' in navigator.storage) {\n            try {\n                const root = await navigator.storage.getDirectory();\n                const handle = await root.getFileHandle(tempFile, { create: true });\n                const syncAccess = await handle.createSyncAccessHandle();\n                syncAccess.close();\n            } catch {\n                ok = false;\n            }\n\n            try {\n                const root = await navigator.storage.getDirectory();\n                await root.removeEntry(tempFile, { recursive: true });\n            } catch {\n                ok = false;\n            }\n\n            return ok;\n        }\n\n        return false;\n    }\n\n    static async isAvailable() {\n        if (this.#isAvailable === undefined) {\n            this.#isAvailable = await this.#computeIsAvailable();\n        }\n\n        return this.#isAvailable;\n    }\n}\n\nexport const removeFromFileStorage = async (filename: string) => {\n    if (await OPFSStorage.isAvailable()) {\n        const root = await navigator.storage.getDirectory();\n\n        try {\n            const cobaltDir = await root.getDirectoryHandle(COBALT_PROCESSING_DIR);\n            await cobaltDir.removeEntry(filename);\n        } catch {\n            // catch and ignore\n        }\n    }\n}\n\nexport const clearFileStorage = async () => {\n    if (await OPFSStorage.isAvailable()) {\n        const root = await navigator.storage.getDirectory();\n        try {\n            await root.removeEntry(COBALT_PROCESSING_DIR, { recursive: true });\n        } catch {\n            // ignore the error because the dir might be missing and that's okay!\n        }\n    }\n}\n"
  },
  {
    "path": "web/src/lib/storage/storage.ts",
    "content": "export abstract class AbstractStorage {\n    static init(_expected_size: number): Promise<AbstractStorage> {\n        throw \"init() call on abstract implementation\";\n    }\n\n    static async isAvailable(): Promise<boolean> {\n        return false;\n    }\n\n    abstract res(): Promise<File>;\n    abstract write(data: Uint8Array | Int8Array, offset: number): Promise<number>;\n    abstract destroy(): Promise<void>;\n};\n"
  },
  {
    "path": "web/src/lib/subnav.ts",
    "content": "import { browser } from \"$app/environment\";\n\nconst defaultNavPage = (page: \"settings\" | \"about\") => {\n    if (browser && window.innerWidth <= 750) {\n        return `/${page}`;\n    }\n\n    switch (page) {\n        case \"settings\":\n            return \"/settings/appearance\";\n        case \"about\":\n            return \"/about/general\";\n    }\n}\n\nexport { defaultNavPage };\n"
  },
  {
    "path": "web/src/lib/task-manager/queue.ts",
    "content": "import { get } from \"svelte/store\";\nimport { t } from \"$lib/i18n/translations\";\nimport { ffmpegMetadataArgs } from \"$lib/util\";\nimport { createDialog } from \"$lib/state/dialogs\";\nimport { addItem } from \"$lib/state/task-manager/queue\";\nimport { openQueuePopover } from \"$lib/state/queue-visibility\";\nimport { uuid } from \"$lib/util\";\n\nimport type { CobaltQueueItem } from \"$lib/types/queue\";\nimport type { CobaltCurrentTasks } from \"$lib/types/task-manager\";\nimport { resultFileTypes, type CobaltPipelineItem, type CobaltPipelineResultFileType } from \"$lib/types/workers\";\nimport type { CobaltLocalProcessingResponse, CobaltSaveRequestBody } from \"$lib/types/api\";\n\nexport const getMediaType = (type: string) => {\n    const kind = type.split('/')[0] as CobaltPipelineResultFileType;\n\n    if (resultFileTypes.includes(kind)) {\n        return kind;\n    }\n}\n\nexport const createRemuxPipeline = (file: File) => {\n    const parentId = uuid();\n    const mediaType = getMediaType(file.type);\n\n    const pipeline: CobaltPipelineItem[] = [{\n        worker: \"remux\",\n        workerId: uuid(),\n        parentId,\n        workerArgs: {\n            files: [file],\n            ffargs: [\n                \"-c\", \"copy\",\n                \"-map\", \"0\"\n            ],\n            output: {\n                type: file.type,\n                format: file.name.split(\".\").pop(),\n            },\n        },\n    }];\n\n    if (mediaType) {\n        addItem({\n            id: parentId,\n            state: \"waiting\",\n            pipeline,\n            filename: file.name,\n            mimeType: file.type,\n            mediaType,\n        });\n\n        openQueuePopover();\n    }\n}\n\nconst makeRemuxArgs = (info: CobaltLocalProcessingResponse) => {\n    const ffargs = [\"-c:v\", \"copy\"];\n\n    if ([\"merge\", \"remux\"].includes(info.type)) {\n        ffargs.push(\"-c:a\", \"copy\");\n    } else if (info.type === \"mute\") {\n        ffargs.push(\"-an\");\n    }\n\n    if (info.output.subtitles) {\n        ffargs.push(\n            \"-c:s\",\n            info.output.filename.endsWith(\".mp4\") ? \"mov_text\" : \"webvtt\"\n        );\n    }\n\n    ffargs.push(\n        ...(info.output.metadata ? ffmpegMetadataArgs(info.output.metadata) : [])\n    );\n\n    return ffargs;\n}\n\nconst makeAudioArgs = (info: CobaltLocalProcessingResponse) => {\n    if (!info.audio) {\n        return;\n    }\n\n    const ffargs = [];\n\n    if (info.audio.cover && info.audio.format === \"mp3\") {\n        ffargs.push(\n            \"-map\", \"0\",\n            \"-map\", \"1\",\n            ...(info.audio.cropCover ? [\n                \"-c:v\", \"mjpeg\",\n                \"-vf\", \"scale=-1:720,crop=720:720\",\n            ] : [\n                \"-c:v\", \"copy\",\n            ]),\n        );\n    } else {\n        ffargs.push(\"-vn\");\n    }\n\n    ffargs.push(\n        ...(info.audio.copy ? [\"-c:a\", \"copy\"] : [\"-b:a\", `${info.audio.bitrate}k`]),\n        ...(info.output.metadata ? ffmpegMetadataArgs(info.output.metadata) : [])\n    );\n\n    if (info.audio.format === \"mp3\" && info.audio.bitrate === \"8\") {\n        ffargs.push(\"-ar\", \"12000\");\n    }\n\n    if (info.audio.format === \"opus\") {\n        ffargs.push(\"-vbr\", \"off\")\n    }\n\n    const outFormat = info.audio.format === \"m4a\" ? \"ipod\" : info.audio.format;\n\n    ffargs.push('-f', outFormat);\n    return ffargs;\n}\n\nconst makeGifArgs = () => {\n    return [\n        \"-vf\",\n        \"scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse\",\n        \"-loop\", \"0\",\n        \"-f\", \"gif\"\n    ];\n}\n\nconst showError = (errorCode: string) => {\n    return createDialog({\n        id: \"pipeline-error\",\n        type: \"small\",\n        meowbalt: \"error\",\n        buttons: [\n            {\n                text: get(t)(\"button.gotit\"),\n                main: true,\n                action: () => {},\n            },\n        ],\n        bodyText: get(t)(`error.${errorCode}`),\n    });\n}\n\nexport const createSavePipeline = (\n    info: CobaltLocalProcessingResponse,\n    request: CobaltSaveRequestBody,\n    oldTaskId?: string\n) => {\n    // this is a pre-queue part of processing,\n    // so errors have to be returned via a regular dialog\n\n    if (!info.output?.filename || !info.output?.type) {\n        return showError(\"pipeline.missing_response_data\");\n    }\n\n    const parentId = oldTaskId || uuid();\n    const pipeline: CobaltPipelineItem[] = [];\n\n    // reverse is needed for audio (second item) to be downloaded first\n    const tunnels = info.tunnel.reverse();\n\n    for (const tunnel of tunnels) {\n        pipeline.push({\n            worker: \"fetch\",\n            workerId: uuid(),\n            parentId,\n            workerArgs: {\n                url: tunnel,\n            },\n        });\n    }\n\n    if (info.type !== \"proxy\") {\n        let ffargs: string[];\n        let workerType: 'encode' | 'remux';\n\n        if ([\"merge\", \"mute\", \"remux\"].includes(info.type)) {\n            workerType = \"remux\";\n            ffargs = makeRemuxArgs(info);\n        } else if (info.type === \"audio\") {\n            const args = makeAudioArgs(info);\n\n            if (!args) {\n                return showError(\"pipeline.missing_response_data\");\n            }\n\n            workerType = \"encode\";\n            ffargs = args;\n        } else if (info.type === \"gif\") {\n            workerType = \"encode\";\n            ffargs = makeGifArgs();\n        } else {\n            console.error(\"unknown work type: \" + info.type);\n            return showError(\"pipeline.missing_response_data\");\n        }\n\n        pipeline.push({\n            worker: workerType,\n            workerId: uuid(),\n            parentId,\n            dependsOn: pipeline.map(w => w.workerId),\n            workerArgs: {\n                files: [],\n                ffargs,\n                output: {\n                    type: info.output.type,\n                    format: info.output.filename.split(\".\").pop(),\n                },\n            },\n        });\n    }\n\n    addItem({\n        id: parentId,\n        state: \"waiting\",\n        pipeline,\n        canRetry: true,\n        originalRequest: request,\n        filename: info.output.filename,\n        mimeType: info.output.type,\n        mediaType: getMediaType(info.output.type) || \"file\",\n    });\n\n    openQueuePopover();\n}\n\nexport const getProgress = (item: CobaltQueueItem, currentTasks: CobaltCurrentTasks): number => {\n    if (item.state === 'done' || item.state === 'error') {\n        return 1;\n    } else if (item.state === 'waiting') {\n        return 0;\n    }\n\n    let sum = 0;\n    for (const worker of item.pipeline) {\n        if (item.pipelineResults[worker.workerId]) {\n            sum += 1;\n        } else {\n            const task = currentTasks[worker.workerId];\n            sum += (task?.progress?.percentage || 0) / 100;\n        }\n    }\n\n    return sum / item.pipeline.length;\n}\n"
  },
  {
    "path": "web/src/lib/task-manager/run-worker.ts",
    "content": "import { get } from \"svelte/store\";\nimport { device } from \"$lib/device\";\nimport { queue, itemError } from \"$lib/state/task-manager/queue\";\n\nimport { runFFmpegWorker } from \"$lib/task-manager/runners/ffmpeg\";\nimport { runFetchWorker } from \"$lib/task-manager/runners/fetch\";\n\nimport type { CobaltPipelineItem } from \"$lib/types/workers\";\n\nexport const killWorker = (worker: Worker, unsubscribe: () => void, interval?: NodeJS.Timeout) => {\n    unsubscribe();\n    worker.terminate();\n    if (interval) clearInterval(interval);\n}\n\nexport const startWorker = async ({ worker, workerId, dependsOn, parentId, workerArgs }: CobaltPipelineItem) => {\n    let files: File[] = [];\n\n    switch (worker) {\n        case \"remux\":\n        case \"encode\": {\n            if (workerArgs.files) {\n                files = workerArgs.files;\n            }\n\n            const parent = get(queue)[parentId];\n            if (parent?.state === \"running\" && dependsOn) {\n                for (const workerId of dependsOn) {\n                    const file = parent.pipelineResults[workerId];\n                    if (!file) {\n                        return itemError(parentId, workerId, \"queue.ffmpeg.no_args\");\n                    }\n\n                    files.push(file);\n                }\n            }\n\n            if (files.length > 0 && workerArgs.ffargs && workerArgs.output) {\n                await runFFmpegWorker(\n                    workerId,\n                    parentId,\n                    files,\n                    workerArgs.ffargs,\n                    workerArgs.output,\n                    worker,\n                    device.supports.multithreading,\n                    /*resetStartCounter=*/true,\n                );\n            } else {\n                itemError(parentId, workerId, \"queue.ffmpeg.no_args\");\n            }\n            break;\n        }\n\n        case \"fetch\":\n            await runFetchWorker(workerId, parentId, workerArgs.url);\n            break;\n    }\n}\n"
  },
  {
    "path": "web/src/lib/task-manager/runners/fetch.ts",
    "content": "import FetchWorker from \"$lib/task-manager/workers/fetch?worker\";\n\nimport { killWorker } from \"$lib/task-manager/run-worker\";\nimport { updateWorkerProgress } from \"$lib/state/task-manager/current-tasks\";\nimport { pipelineTaskDone, itemError, queue } from \"$lib/state/task-manager/queue\";\n\nimport type { CobaltQueue, UUID } from \"$lib/types/queue\";\n\nexport const runFetchWorker = async (workerId: UUID, parentId: UUID, url: string) => {\n    const worker = new FetchWorker();\n\n    const unsubscribe = queue.subscribe((queue: CobaltQueue) => {\n        if (!queue[parentId]) {\n            killWorker(worker, unsubscribe);\n        }\n    });\n\n    worker.postMessage({\n        cobaltFetchWorker: {\n            url\n        }\n    });\n\n    worker.onmessage = (event) => {\n        const eventData = event.data.cobaltFetchWorker;\n        if (!eventData) return;\n\n        if (eventData.progress) {\n            updateWorkerProgress(workerId, {\n                percentage: eventData.progress,\n                size: eventData.size,\n            })\n        }\n\n        if (eventData.result) {\n            killWorker(worker, unsubscribe);\n            return pipelineTaskDone(\n                parentId,\n                workerId,\n                eventData.result,\n            );\n        }\n\n        if (eventData.error) {\n            killWorker(worker, unsubscribe);\n            return itemError(parentId, workerId, eventData.error);\n        }\n    }\n}\n"
  },
  {
    "path": "web/src/lib/task-manager/runners/ffmpeg.ts",
    "content": "import FFmpegWorker from \"$lib/task-manager/workers/ffmpeg?worker\";\n\nimport { killWorker } from \"$lib/task-manager/run-worker\";\nimport { updateWorkerProgress } from \"$lib/state/task-manager/current-tasks\";\nimport { pipelineTaskDone, itemError, queue } from \"$lib/state/task-manager/queue\";\n\nimport type { FileInfo } from \"$lib/types/libav\";\nimport type { CobaltQueue } from \"$lib/types/queue\";\n\nlet startAttempts = 0;\n\nexport const runFFmpegWorker = async (\n    workerId: string,\n    parentId: string,\n    files: File[],\n    args: string[],\n    output: FileInfo,\n    variant: 'remux' | 'encode',\n    yesthreads: boolean,\n    resetStartCounter = false,\n) => {\n    const worker = new FFmpegWorker();\n\n    // sometimes chrome refuses to start libav wasm,\n    // so we check if it started, try 10 more times if not, and kill self if it still doesn't work\n    // TODO: fix the underlying issue because this is ridiculous\n\n    if (resetStartCounter) startAttempts = 0;\n\n    let bumpAttempts = 0;\n    const startCheck = setInterval(async () => {\n        bumpAttempts++;\n\n        if (bumpAttempts === 10) {\n            startAttempts++;\n            if (startAttempts <= 10) {\n                killWorker(worker, unsubscribe, startCheck);\n                return await runFFmpegWorker(\n                    workerId, parentId,\n                    files, args, output,\n                    variant, yesthreads\n                );\n            } else {\n                killWorker(worker, unsubscribe, startCheck);\n                return itemError(parentId, workerId, \"queue.worker_didnt_start\");\n            }\n        }\n    }, 500);\n\n    const unsubscribe = queue.subscribe((queue: CobaltQueue) => {\n        if (!queue[parentId]) {\n            killWorker(worker, unsubscribe, startCheck);\n        }\n    });\n\n    worker.postMessage({\n        cobaltFFmpegWorker: {\n            variant,\n            files,\n            args,\n            output,\n            yesthreads,\n        }\n    });\n\n    worker.onerror = (e) => {\n        console.error(\"ffmpeg worker crashed:\", e);\n        killWorker(worker, unsubscribe, startCheck);\n\n        return itemError(parentId, workerId, \"queue.generic_error\");\n    };\n\n    let totalDuration: number | null = null;\n\n    worker.onmessage = (event) => {\n        const eventData = event.data.cobaltFFmpegWorker;\n        if (!eventData) return;\n\n        clearInterval(startCheck);\n\n        if (eventData.progress) {\n            if (eventData.progress.duration) {\n                totalDuration = eventData.progress.duration;\n            }\n\n            updateWorkerProgress(workerId, {\n                percentage: totalDuration ? (eventData.progress.durationProcessed / totalDuration) * 100 : 0,\n                size: eventData.progress.size,\n            })\n        }\n\n        if (eventData.render) {\n            killWorker(worker, unsubscribe, startCheck);\n            return pipelineTaskDone(\n                parentId,\n                workerId,\n                eventData.render,\n            );\n        }\n\n        if (eventData.error) {\n            killWorker(worker, unsubscribe, startCheck);\n            return itemError(parentId, workerId, eventData.error);\n        }\n    };\n}\n"
  },
  {
    "path": "web/src/lib/task-manager/scheduler.ts",
    "content": "import { get } from \"svelte/store\";\nimport { startWorker } from \"$lib/task-manager/run-worker\";\nimport { addWorkerToQueue, currentTasks } from \"$lib/state/task-manager/current-tasks\";\nimport { itemDone, itemError, itemRunning, queue } from \"$lib/state/task-manager/queue\";\n\nimport type { CobaltPipelineItem } from \"$lib/types/workers\";\n\nconst startPipeline = (pipelineItem: CobaltPipelineItem) => {\n    addWorkerToQueue(pipelineItem.workerId, {\n        type: pipelineItem.worker,\n        parentId: pipelineItem.parentId,\n    });\n\n    itemRunning(pipelineItem.parentId);\n    startWorker(pipelineItem);\n}\n\n// this is really messy, sorry to whoever\n// reads this in the future (probably myself)\nexport const schedule = () => {\n    const queueItems = get(queue);\n    const ongoingTasks = get(currentTasks);\n\n    for (const task of Object.values(queueItems)) {\n        if (task.state === \"running\") {\n            const finalWorker = task.pipeline[task.pipeline.length - 1];\n\n            // if all workers are completed, then return the\n            // the final file and go to the next task\n            if (Object.keys(task.pipelineResults).length === task.pipeline.length) {\n                // remove the final file from pipeline results, so that it doesn't\n                // get deleted when we clean up the intermediate files\n                const finalFile = task.pipelineResults[finalWorker.workerId];\n                delete task.pipelineResults[finalWorker.workerId];\n\n                if (finalFile) {\n                    itemDone(task.id, finalFile);\n                } else {\n                    itemError(task.id, finalWorker.workerId, \"queue.no_final_file\");\n                }\n\n                continue;\n            }\n\n            // if current worker is completed, but there are more workers,\n            // then start the next one and wait to be called again\n            for (const worker of task.pipeline) {\n                if (task.pipelineResults[worker.workerId] || ongoingTasks[worker.workerId]) {\n                    continue;\n                }\n\n                const needsToWait = worker.dependsOn?.some(id => !task.pipelineResults[id]);\n                if (needsToWait) {\n                    break;\n                }\n\n                startPipeline(worker);\n            }\n\n            // break because we don't want to start next tasks before this one is done\n            // it's necessary because some tasks might take some time before being marked as running\n            break;\n        }\n\n        // start the nearest waiting task and wait to be called again\n        else if (task.state === \"waiting\" && task.pipeline.length > 0 && Object.keys(ongoingTasks).length === 0) {\n            // this is really bad but idk how to prevent tasks from running simultaneously\n            // on retry if a later task is running & user restarts an old task\n            for (const task of Object.values(queueItems)) {\n                if (task.state === \"running\") return;\n            }\n\n            startPipeline(task.pipeline[0]);\n\n            // break because we don't want to start next tasks before this one is done\n            // it's necessary because some tasks might take some time before being marked as running\n            break;\n        }\n    }\n}\n"
  },
  {
    "path": "web/src/lib/task-manager/workers/fetch.ts",
    "content": "import * as Storage from \"$lib/storage\";\n\nconst networkErrors = [\n    \"TypeError: Failed to fetch\",\n    \"TypeError: network error\",\n];\n\nlet attempts = 0;\n\nconst fetchFile = async (url: string) => {\n    const error = async (code: string, retry: boolean = true) => {\n        attempts++;\n\n        // try 3 more times before actually failing\n        if (retry && attempts <= 3) {\n            await fetchFile(url);\n        } else {\n            self.postMessage({\n                cobaltFetchWorker: {\n                    error: code,\n                }\n            });\n            return self.close();\n        }\n    };\n\n    try {\n        const response = await fetch(url);\n\n        if (!response.ok) {\n            return error(\"queue.fetch.bad_response\");\n        }\n\n        const contentType = response.headers.get('Content-Type')\n                                || 'application/octet-stream';\n\n        const contentLength = response.headers.get('Content-Length');\n        const estimatedLength = response.headers.get('Estimated-Content-Length');\n\n        let expectedSize;\n\n        if (contentLength) {\n            expectedSize = +contentLength;\n        } else if (estimatedLength) {\n            expectedSize = +estimatedLength;\n        }\n\n        const reader = response.body?.getReader();\n\n        const storage = await Storage.init(expectedSize);\n\n        if (!reader) {\n            return error(\"queue.fetch.no_file_reader\");\n        }\n\n        let receivedBytes = 0;\n\n        while (true) {\n            const { done, value } = await reader.read();\n            if (done) break;\n\n            await storage.write(value, receivedBytes);\n            receivedBytes += value.length;\n\n            if (expectedSize) {\n                self.postMessage({\n                    cobaltFetchWorker: {\n                        progress: Math.round((receivedBytes / expectedSize) * 100),\n                        size: receivedBytes,\n                    }\n                });\n            }\n        }\n\n        if (receivedBytes === 0) {\n            return error(\"queue.fetch.empty_tunnel\");\n        }\n\n        const file = Storage.retype(await storage.res(), contentType);\n\n        if (contentLength && Number(contentLength) !== file.size) {\n            return error(\"queue.fetch.corrupted_file\", false);\n        }\n\n        self.postMessage({\n            cobaltFetchWorker: {\n                result: file\n            }\n        });\n    } catch (e) {\n        // retry several times if the error is network-related\n        if (networkErrors.includes(String(e))) {\n            return error(\"queue.fetch.network_error\");\n        }\n        console.error(\"error from the fetch worker:\");\n        console.error(e);\n        return error(\"queue.fetch.crashed\", false);\n    }\n}\n\nself.onmessage = async (event: MessageEvent) => {\n    if (event.data.cobaltFetchWorker) {\n        await fetchFile(event.data.cobaltFetchWorker.url);\n        self.close();\n    }\n}\n"
  },
  {
    "path": "web/src/lib/task-manager/workers/ffmpeg.ts",
    "content": "import LibAVWrapper from \"$lib/libav\";\nimport type { FileInfo } from \"$lib/types/libav\";\n\nconst ffmpeg = async (\n    variant: string,\n    files: File[],\n    args: string[],\n    output: FileInfo,\n    yesthreads: boolean = false,\n) => {\n    if (!(files && output && args)) {\n        self.postMessage({\n            cobaltFFmpegWorker: {\n                error: \"queue.ffmpeg.no_args\",\n            }\n        });\n        return;\n    }\n\n    const ff = new LibAVWrapper((progress) => {\n        self.postMessage({\n            cobaltFFmpegWorker: {\n                progress: {\n                    durationProcessed: progress.out_time_sec,\n                    speed: progress.speed,\n                    size: progress.total_size,\n                    currentFrame: progress.frame,\n                    fps: progress.fps,\n                }\n            }\n        })\n    });\n\n    ff.init({ variant, yesthreads });\n\n    const error = (code: string) => {\n        self.postMessage({\n            cobaltFFmpegWorker: {\n                error: code,\n            }\n        });\n        ff.terminate();\n    }\n\n    try {\n        // probing just the first file in files array (usually audio) for duration progress\n        const probeFile = files[0];\n        if (!probeFile) {\n            return error(\"queue.ffmpeg.probe_failed\");\n        }\n\n        let file_info;\n\n        try {\n            file_info = await ff.probe(probeFile);\n        } catch (e) {\n            console.error(\"error from ffmpeg worker @ file_info:\");\n            if (e instanceof Error && e?.message?.toLowerCase().includes(\"out of memory\")) {\n                console.error(e);\n\n                error(\"queue.ffmpeg.out_of_memory\");\n                return self.close();\n            } else {\n                console.error(e);\n                return error(\"queue.ffmpeg.probe_failed\");\n            }\n        }\n\n        if (!file_info?.format) {\n            return error(\"queue.ffmpeg.no_input_format\");\n        }\n\n        // handle the edge case when a video doesn't have an audio track\n        // but user still tries to extract it\n        if (files.length === 1 && file_info.streams?.length === 1) {\n            if (output.type?.startsWith(\"audio\") && file_info.streams[0].codec_type !== \"audio\") {\n                return error(\"queue.ffmpeg.no_audio_channel\");\n            }\n        }\n\n        self.postMessage({\n            cobaltFFmpegWorker: {\n                progress: {\n                    duration: Number(file_info.format.duration),\n                }\n            }\n        });\n\n        for (const file of files) {\n            if (!file.type) {\n                return error(\"queue.ffmpeg.no_input_type\");\n            }\n        }\n\n        let render;\n\n        try {\n            render = await ff.render({\n                files,\n                output,\n                args,\n            });\n        } catch (e) {\n            console.error(\"error from the ffmpeg worker @ render:\");\n            console.error(e);\n            // TODO: more granular error codes\n            return error(\"queue.ffmpeg.crashed\");\n        }\n\n        if (!render) {\n            return error(\"queue.ffmpeg.no_render\");\n        }\n\n        await ff.terminate();\n\n        self.postMessage({\n            cobaltFFmpegWorker: {\n                render\n            }\n        });\n    } catch (e) {\n        console.error(\"error from the ffmpeg worker:\")\n        console.error(e);\n        return error(\"queue.ffmpeg.crashed\");\n    }\n}\n\nself.onmessage = async (event: MessageEvent) => {\n    const ed = event.data.cobaltFFmpegWorker;\n    if (ed?.variant && ed?.files && ed?.args && ed?.output) {\n        await ffmpeg(ed.variant, ed.files, ed.args, ed.output, ed.yesthreads);\n    }\n}\n"
  },
  {
    "path": "web/src/lib/types/api.ts",
    "content": "import type { CobaltSettings } from \"$lib/types/settings\";\n\nenum CobaltResponseType {\n    Error = 'error',\n    Picker = 'picker',\n    Redirect = 'redirect',\n    Tunnel = 'tunnel',\n    LocalProcessing = 'local-processing',\n}\n\nexport type CobaltErrorResponse = {\n    status: CobaltResponseType.Error,\n    error: {\n        code: string,\n        context?: {\n            service?: string,\n            limit?: number,\n        }\n    },\n};\n\ntype CobaltPartialURLResponse = {\n    url: string,\n    filename: string,\n}\n\ntype CobaltPickerResponse = {\n    status: CobaltResponseType.Picker\n    picker: {\n        type: 'photo' | 'video' | 'gif',\n        url: string,\n        thumb?: string,\n    }[];\n    audio?: string,\n    audioFilename?: string,\n};\n\ntype CobaltRedirectResponse = {\n    status: CobaltResponseType.Redirect,\n} & CobaltPartialURLResponse;\n\ntype CobaltTunnelResponse = {\n    status: CobaltResponseType.Tunnel,\n} & CobaltPartialURLResponse;\n\nexport const CobaltFileMetadataKeys = [\n    'album',\n    'composer',\n    'genre',\n    'copyright',\n    'title',\n    'artist',\n    'album_artist',\n    'track',\n    'date',\n    'sublanguage',\n];\n\nexport type CobaltFileMetadata = Record<\n    typeof CobaltFileMetadataKeys[number], string | undefined\n>;\n\nexport type CobaltLocalProcessingType = 'merge' | 'mute' | 'audio' | 'gif' | 'remux' | 'proxy';\n\nexport type CobaltLocalProcessingResponse = {\n    status: CobaltResponseType.LocalProcessing,\n\n    type: CobaltLocalProcessingType,\n    service: string,\n    tunnel: string[],\n\n    output: {\n        type: string, // mimetype\n        filename: string,\n        metadata?: CobaltFileMetadata,\n        subtitles?: boolean,\n    },\n\n    audio?: {\n        copy: boolean,\n        format: string,\n        bitrate: string,\n        cover?: boolean,\n        cropCover?: boolean,\n    },\n\n    isHLS?: boolean,\n}\n\nexport type CobaltFileUrlType = \"redirect\" | \"tunnel\";\n\nexport type CobaltSession = {\n    token: string,\n    exp: number,\n}\n\nexport type CobaltServerInfo = {\n    cobalt: {\n        version: string,\n        url: string,\n        startTime: string,\n        turnstileSitekey?: string,\n        services: string[]\n    },\n    git: {\n        branch: string,\n        commit: string,\n        remote: string,\n    }\n}\n\n// TODO: strict partial\n// this allows for extra properties, which is not ideal,\n// but i couldn't figure out how to make a strict partial :(\nexport type CobaltSaveRequestBody =\n    { url: string } & Partial<Omit<CobaltSettings['save'], 'savingMethod'>>;\n\nexport type CobaltSessionResponse = CobaltSession | CobaltErrorResponse;\nexport type CobaltServerInfoResponse = CobaltServerInfo | CobaltErrorResponse;\n\nexport type CobaltAPIResponse = CobaltErrorResponse\n                              | CobaltPickerResponse\n                              | CobaltRedirectResponse\n                              | CobaltTunnelResponse\n                              | CobaltLocalProcessingResponse;\n"
  },
  {
    "path": "web/src/lib/types/changelogs.ts",
    "content": "export interface ChangelogMetadata {\n    title: string,\n    date: string,\n    banner?: {\n        file: string,\n        alt: string\n    }\n};\n\nexport interface MarkdownMetadata {\n    metadata: ChangelogMetadata\n};\n\nexport type ChangelogImport = {\n    default: ConstructorOfATypedSvelteComponent,\n    metadata: ChangelogMetadata\n};"
  },
  {
    "path": "web/src/lib/types/dialog.ts",
    "content": "import type { CobaltFileUrlType } from \"$lib/types/api\";\nimport type { MeowbaltEmotions } from \"$lib/types/meowbalt\";\n\nexport type DialogButton = {\n    text: string,\n    color?: \"red\",\n    main: boolean,\n    timeout?: number, // milliseconds\n    action: () => unknown | Promise<unknown>,\n    link?: string\n}\n\nexport type SmallDialogIcons = \"warn-red\";\n\nexport type DialogPickerItem = {\n    type?: 'photo' | 'video' | 'gif',\n    url: string,\n    thumb?: string,\n}\n\ntype Dialog = {\n    id: string,\n    dismissable?: boolean,\n};\n\ntype SmallDialog = Dialog & {\n    type: \"small\",\n    meowbalt?: MeowbaltEmotions,\n    icon?: SmallDialogIcons,\n    title?: string,\n    bodyText?: string,\n    bodySubText?: string,\n    buttons?: DialogButton[],\n    leftAligned?: boolean,\n};\n\ntype PickerDialog = Dialog & {\n    type: \"picker\",\n    items?: DialogPickerItem[],\n    buttons?: DialogButton[],\n};\n\ntype SavingDialog = Dialog & {\n    type: \"saving\",\n    bodyText?: string,\n    url?: string,\n    file?: File,\n    urlType?: CobaltFileUrlType,\n};\n\nexport type DialogInfo = SmallDialog | PickerDialog | SavingDialog;\n"
  },
  {
    "path": "web/src/lib/types/generic.ts",
    "content": "import type { Readable } from \"svelte/store\";\n\n// more readable version of recursive partial taken from stackoverflow:\n// https://stackoverflow.com/a/51365037\nexport type RecursivePartial<Type> = {\n    [Key in keyof Type]?:\n    Type[Key] extends (infer ElementType)[] ? RecursivePartial<ElementType>[] :\n    Type[Key] extends object | undefined ? RecursivePartial<Type[Key]> :\n    Type[Key];\n};\n\nexport type DefaultImport<T> = () => Promise<{ default: T }>;\nexport type Optional<T> = T | undefined;\nexport type Writeable<T> = { -readonly [P in keyof T]: T[P] };\nexport type FromReadable<T> = T extends Readable<infer U> ? U : never;\n"
  },
  {
    "path": "web/src/lib/types/i18n.ts",
    "content": "import type { DefaultImport } from '$lib/types/generic';\n\ntype LanguageCode = string;\ntype KeyPath = string;\n\nexport type GenericImport = DefaultImport<unknown>;\nexport type LocalizationContent = Record<string, string>;\nexport type StructuredLocfileInfo = Record<\n    LanguageCode,\n    Record<KeyPath, GenericImport>\n>;\n"
  },
  {
    "path": "web/src/lib/types/libav.ts",
    "content": "export type FileInfo = {\n    type?: string,\n    format?: string,\n}\n\nexport type RenderParams = {\n    files: File[],\n    output: FileInfo,\n    args: string[],\n}\n\nexport type FFmpegProgressStatus = \"continue\" | \"end\" | \"unknown\";\nexport type FFmpegProgressEvent = {\n    status: FFmpegProgressStatus,\n    frame?: number,\n    fps?: number,\n    total_size?: number,\n    dup_frames?: number,\n    drop_frames?: number,\n    speed?: number,\n    out_time_sec?: number,\n}\n\nexport type FFmpegProgressCallback = (info: FFmpegProgressEvent) => void;\n"
  },
  {
    "path": "web/src/lib/types/meowbalt.ts",
    "content": "export type MeowbaltEmotions = \"smile\" | \"error\" | \"question\" | \"think\" | \"fast\";"
  },
  {
    "path": "web/src/lib/types/omnibox.ts",
    "content": "export type CobaltDownloadButtonState = \"idle\" | \"think\" | \"check\" | \"done\" | \"error\";\n"
  },
  {
    "path": "web/src/lib/types/queue.ts",
    "content": "import type { CobaltSaveRequestBody } from \"$lib/types/api\";\nimport type { CobaltPipelineItem, CobaltPipelineResultFileType } from \"$lib/types/workers\";\n\nexport type UUID = string;\n\ntype CobaltQueueBaseItem = {\n    id: UUID,\n    pipeline: CobaltPipelineItem[],\n    canRetry?: boolean,\n    originalRequest?: CobaltSaveRequestBody,\n    filename: string,\n    mimeType?: string,\n    mediaType: CobaltPipelineResultFileType,\n};\n\ntype CobaltQueueItemWaiting = CobaltQueueBaseItem & {\n    state: \"waiting\",\n};\n\nexport type CobaltQueueItemRunning = CobaltQueueBaseItem & {\n    state: \"running\",\n    pipelineResults: Record<UUID, File>,\n};\n\ntype CobaltQueueItemDone = CobaltQueueBaseItem & {\n    state: \"done\",\n    resultFile: File,\n};\n\ntype CobaltQueueItemError = CobaltQueueBaseItem & {\n    state: \"error\",\n    errorCode: string,\n};\n\nexport type CobaltQueueItem = CobaltQueueItemWaiting\n                            | CobaltQueueItemRunning\n                            | CobaltQueueItemDone\n                            | CobaltQueueItemError;\n\nexport type CobaltQueue = {\n    [id: UUID]: CobaltQueueItem,\n};\n"
  },
  {
    "path": "web/src/lib/types/settings/v2.ts",
    "content": "import languages from \"$i18n/languages.json\";\n\nexport const themeOptions = [\"auto\", \"light\", \"dark\"] as const;\nexport const audioBitrateOptions = [\"320\", \"256\", \"128\", \"96\", \"64\", \"8\"] as const;\nexport const audioFormatOptions = [\"best\", \"mp3\", \"ogg\", \"wav\", \"opus\"] as const;\nexport const downloadModeOptions = [\"auto\", \"audio\", \"mute\"] as const;\nexport const filenameStyleOptions = [\"classic\", \"basic\", \"pretty\", \"nerdy\"] as const;\nexport const videoQualityOptions = [\"max\", \"2160\", \"1440\", \"1080\", \"720\", \"480\", \"360\", \"240\", \"144\"] as const;\nexport const youtubeVideoCodecOptions = [\"h264\", \"av1\", \"vp9\"] as const;\nexport const savingMethodOptions = [\"ask\", \"download\", \"share\", \"copy\"] as const;\n\ntype CobaltSettingsAppearance = {\n    theme: typeof themeOptions[number],\n    language: keyof typeof languages,\n    autoLanguage: boolean,\n    reduceMotion: boolean,\n    reduceTransparency: boolean,\n};\n\ntype CobaltSettingsAdvanced = {\n    debug: boolean,\n};\n\ntype CobaltSettingsPrivacy = {\n    alwaysProxy: boolean,\n    disableAnalytics: boolean,\n};\n\ntype CobaltSettingsProcessing = {\n    allowDefaultOverride: boolean,\n    customInstanceURL: string,\n    enableCustomInstances: boolean,\n    seenCustomWarning: boolean,\n    seenOverrideWarning: boolean,\n}\n\ntype CobaltSettingsSaveV2 = {\n    audioFormat: typeof audioFormatOptions[number],\n    audioBitrate: typeof audioBitrateOptions[number],\n    disableMetadata: boolean,\n    downloadMode: typeof downloadModeOptions[number],\n    filenameStyle: typeof filenameStyleOptions[number],\n    savingMethod: typeof savingMethodOptions[number],\n    tiktokH265: boolean,\n    tiktokFullAudio: boolean,\n    twitterGif: boolean,\n    videoQuality: typeof videoQualityOptions[number],\n    youtubeVideoCodec: typeof youtubeVideoCodecOptions[number],\n    youtubeDubBrowserLang: boolean,\n    youtubeHLS: boolean,\n};\n\nexport type CobaltSettingsV2 = {\n    schemaVersion: 2,\n    advanced: CobaltSettingsAdvanced,\n    appearance: CobaltSettingsAppearance,\n    save: CobaltSettingsSaveV2,\n    privacy: CobaltSettingsPrivacy,\n    processing: CobaltSettingsProcessing,\n};\n"
  },
  {
    "path": "web/src/lib/types/settings/v3.ts",
    "content": "import type { YoutubeDubLang } from \"$lib/settings/audio-sub-language\";\nimport { type CobaltSettingsV2 } from \"$lib/types/settings/v2\";\n\nexport type CobaltSettingsV3 = Omit<CobaltSettingsV2, 'schemaVersion' | 'save'> & {\n    schemaVersion: 3,\n    save: Omit<CobaltSettingsV2['save'], 'youtubeDubBrowserLang'> & {\n        youtubeDubLang: YoutubeDubLang;\n    };\n};\n"
  },
  {
    "path": "web/src/lib/types/settings/v4.ts",
    "content": "import { type CobaltSettingsV3 } from \"$lib/types/settings/v3\";\n\nexport type CobaltSettingsV4 = Omit<CobaltSettingsV3, 'schemaVersion' | 'processing'> & {\n    schemaVersion: 4,\n    processing: Omit<CobaltSettingsV3['processing'], 'allowDefaultOverride' | 'seenOverrideWarning'> & {\n        customApiKey: string;\n        enableCustomApiKey: boolean;\n    };\n};\n"
  },
  {
    "path": "web/src/lib/types/settings/v5.ts",
    "content": "import { type CobaltSettingsV4 } from \"$lib/types/settings/v4\";\n\nexport type CobaltSettingsV5 = Omit<CobaltSettingsV4, 'schemaVersion' | 'advanced' | 'save' | 'privacy' | 'appearance'> & {\n    schemaVersion: 5,\n    appearance: Omit<CobaltSettingsV4['appearance'], 'reduceMotion' | 'reduceTransparency'> & {\n        hideRemuxTab: boolean,\n    },\n    accessibility: {\n        reduceMotion: boolean;\n        reduceTransparency: boolean;\n        disableHaptics: boolean;\n        dontAutoOpenQueue: boolean;\n    },\n    advanced: CobaltSettingsV4['advanced'] & {\n        useWebCodecs: boolean;\n    },\n    privacy: Omit<CobaltSettingsV4['privacy'], 'alwaysProxy'>,\n    save: Omit<CobaltSettingsV4['save'], 'tiktokH265' | 'twitterGif'> & {\n        alwaysProxy: boolean;\n        localProcessing: boolean;\n        allowH265: boolean;\n        convertGif: boolean;\n        youtubeBetterAudio: boolean;\n    },\n};\n"
  },
  {
    "path": "web/src/lib/types/settings/v6.ts",
    "content": "import type { SubtitleLang } from \"$lib/settings/audio-sub-language\";\nimport type { CobaltSettingsV5 } from \"$lib/types/settings/v5\";\n\nexport const youtubeVideoContainerOptions = [\"auto\", \"mp4\", \"webm\", \"mkv\"] as const;\nexport const localProcessingOptions = [\"disabled\", \"preferred\", \"forced\"] as const;\n\nexport type CobaltSettingsV6 = Omit<CobaltSettingsV5, 'schemaVersion' | 'save'> & {\n    schemaVersion: 6,\n    save: Omit<CobaltSettingsV5['save'], 'localProcessing'> & {\n        localProcessing: typeof localProcessingOptions[number],\n        youtubeVideoContainer: typeof youtubeVideoContainerOptions[number];\n        subtitleLang: SubtitleLang,\n    },\n};\n"
  },
  {
    "path": "web/src/lib/types/settings.ts",
    "content": "import type { RecursivePartial } from \"$lib/types/generic\";\nimport type { CobaltSettingsV2 } from \"$lib/types/settings/v2\";\nimport type { CobaltSettingsV3 } from \"$lib/types/settings/v3\";\nimport type { CobaltSettingsV4 } from \"$lib/types/settings/v4\";\nimport type { CobaltSettingsV5 } from \"$lib/types/settings/v5\";\nimport type { CobaltSettingsV6 } from \"$lib/types/settings/v6\";\n\nexport * from \"$lib/types/settings/v2\";\nexport * from \"$lib/types/settings/v3\";\nexport * from \"$lib/types/settings/v4\";\nexport * from \"$lib/types/settings/v5\";\nexport * from \"$lib/types/settings/v6\";\n\nexport type CobaltSettings = CobaltSettingsV6;\n\nexport type AnyCobaltSettings = CobaltSettingsV5 | CobaltSettingsV4 | CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings;\n\nexport type PartialSettings = RecursivePartial<CobaltSettings>;\n\nexport type AllPartialSettingsWithSchema = RecursivePartial<AnyCobaltSettings> & { schemaVersion: number };\n\nexport type DownloadModeOption = CobaltSettings['save']['downloadMode'];\n"
  },
  {
    "path": "web/src/lib/types/task-manager.ts",
    "content": "import type { CobaltPipelineItem, CobaltWorkerProgress } from \"$lib/types/workers\";\nimport type { UUID } from \"./queue\";\n\nexport type CobaltCurrentTaskItem = {\n    type: CobaltPipelineItem['worker'],\n    parentId: UUID,\n    progress?: CobaltWorkerProgress,\n}\n\nexport type CobaltCurrentTasks = {\n    [id: UUID]: CobaltCurrentTaskItem,\n}\n"
  },
  {
    "path": "web/src/lib/types/workers.ts",
    "content": "import type { FileInfo } from \"$lib/types/libav\";\nimport type { UUID } from \"./queue\";\n\nexport const resultFileTypes = [\"video\", \"audio\", \"image\", \"file\"] as const;\n\nexport type CobaltPipelineResultFileType = typeof resultFileTypes[number];\n\nexport type CobaltWorkerProgress = {\n    percentage?: number,\n    speed?: number,\n    size: number,\n};\n\ntype CobaltFFmpegWorkerArgs = {\n    files: File[],\n    ffargs: string[],\n    output: FileInfo,\n};\n\ntype CobaltPipelineItemBase = {\n    workerId: UUID,\n    parentId: UUID,\n    dependsOn?: UUID[],\n};\n\ntype CobaltRemuxPipelineItem = CobaltPipelineItemBase & {\n    worker: \"remux\",\n    workerArgs: CobaltFFmpegWorkerArgs,\n}\n\ntype CobaltEncodePipelineItem = CobaltPipelineItemBase & {\n    worker: \"encode\",\n    workerArgs: CobaltFFmpegWorkerArgs,\n}\n\ntype CobaltFetchPipelineItem = CobaltPipelineItemBase & {\n    worker: \"fetch\",\n    workerArgs: { url: string },\n}\n\nexport type CobaltPipelineItem = CobaltEncodePipelineItem\n                               | CobaltRemuxPipelineItem\n                               | CobaltFetchPipelineItem;\n"
  },
  {
    "path": "web/src/lib/util.ts",
    "content": "import { CobaltFileMetadataKeys, type CobaltFileMetadata } from \"$lib/types/api\";\n\nexport const formatFileSize = (size: number | undefined) => {\n    size ||= 0;\n\n    // gigabyte, megabyte, kilobyte, byte\n    const units = ['G', 'M', 'K', ''];\n    while (size >= 1024 && units.length > 1) {\n        size /= 1024;\n        units.pop();\n    }\n\n    const roundedSize = size.toFixed(2);\n    const unit = units[units.length - 1] + \"B\";\n    return `${roundedSize} ${unit}`;\n}\n\nexport const ffmpegMetadataArgs = (metadata: CobaltFileMetadata) =>\n    Object.entries(metadata).flatMap(([name, value]) => {\n        if (CobaltFileMetadataKeys.includes(name) && typeof value === \"string\") {\n            if (name === \"sublanguage\") {\n                return [\n                    '-metadata:s:s:0',\n                    // eslint-disable-next-line no-control-regex\n                    `language=${value.replace(/[\\u0000-\\u0009]/g, \"\")}`\n                ]\n            }\n            return [\n                '-metadata',\n                // eslint-disable-next-line no-control-regex\n                `${name}=${value.replace(/[\\u0000-\\u0009]/g, \"\")}`\n            ]\n        }\n        return [];\n    });\n\nconst digit = () => '0123456789abcdef'[Math.random() * 16 | 0];\nexport const uuid = () => {\n    if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {\n        return crypto.randomUUID();\n    }\n\n    const digits = Array.from({length: 32}, digit);\n    digits[12] = '4';\n    digits[16] = '89ab'[Math.random() * 4 | 0];\n\n    return digits\n            .join('')\n            .match(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/)!\n            .slice(1)\n            .join('-');\n}\n"
  },
  {
    "path": "web/src/lib/version.ts",
    "content": "import { readable } from \"svelte/store\";\nimport type { Optional } from \"./types/generic\";\nimport { browser } from \"$app/environment\";\n\ntype VersionResponse = {\n    commit: string;\n    branch: string;\n    remote: string;\n    version: string;\n}\n\nexport const version = readable<Optional<VersionResponse>>(\n    undefined,\n    (set) => {\n        if (!browser) return;\n\n        fetch('/version.json')\n            .then(r => r.json())\n            .then(set)\n            .catch(() => {})\n    }\n)\n"
  },
  {
    "path": "web/src/routes/+error.svelte",
    "content": "<script lang=\"ts\">\n    import { onMount } from \"svelte\";\n    import { page } from \"$app/state\";\n    import { goto } from \"$app/navigation\";\n    import { defaultNavPage } from \"$lib/subnav\";\n\n    onMount(() => {\n        if (page.error?.message === \"Not Found\") {\n            if (page.url.pathname.startsWith(\"/settings\")) {\n                goto(defaultNavPage(\"settings\"), { replaceState: true });\n            } else if (page.url.pathname.startsWith(\"/about\")) {\n                goto(defaultNavPage(\"about\"), { replaceState: true });\n            } else {\n                goto(\"/\", { replaceState: true });\n            }\n        }\n    });\n</script>\n"
  },
  {
    "path": "web/src/routes/+layout.svelte",
    "content": "<script lang=\"ts\">\n    import \"../app.css\";\n    import \"../fonts/noto-mono-cobalt.css\";\n\n    import \"@fontsource/ibm-plex-mono/400.css\";\n    import \"@fontsource/ibm-plex-mono/400-italic.css\";\n    import \"@fontsource/ibm-plex-mono/500.css\";\n\n    import { onMount } from \"svelte\";\n    import { page } from \"$app/stores\";\n    import { updated } from \"$app/stores\";\n    import { browser } from \"$app/environment\";\n    import { afterNavigate } from \"$app/navigation\";\n\n    import \"$lib/polyfills\";\n    import env from \"$lib/env\";\n    import locale from \"$lib/i18n/locale\";\n    import settings from \"$lib/state/settings\";\n\n    import { t } from \"$lib/i18n/translations\";\n\n    import { device, app } from \"$lib/device\";\n    import { getServerInfo } from \"$lib/api/server-info\";\n    import currentTheme, { statusBarColors } from \"$lib/state/theme\";\n    import { turnstileCreated, turnstileEnabled } from \"$lib/state/turnstile\";\n\n    import Sidebar from \"$components/sidebar/Sidebar.svelte\";\n    import Turnstile from \"$components/misc/Turnstile.svelte\";\n    import NotchSticker from \"$components/misc/NotchSticker.svelte\";\n    import DialogHolder from \"$components/dialog/DialogHolder.svelte\";\n    import ProcessingQueue from \"$components/queue/ProcessingQueue.svelte\";\n    import UpdateNotification from \"$components/misc/UpdateNotification.svelte\";\n\n    $: reduceMotion =\n        $settings.accessibility.reduceMotion || device.prefers.reducedMotion;\n\n    $: reduceTransparency =\n        $settings.accessibility.reduceTransparency ||\n        device.prefers.reducedTransparency;\n\n    $: preloadAssets = false;\n    $: plausibleLoaded = false;\n\n    afterNavigate(async () => {\n        const to_focus: HTMLElement | null =\n            document.querySelector(\"[data-first-focus]\");\n        to_focus?.focus();\n\n        if ($page.url.pathname === \"/\") {\n            await getServerInfo();\n        }\n    });\n\n    onMount(() => {\n        preloadAssets = true;\n    });\n</script>\n\n<svelte:head>\n    <meta name=\"description\" content={$t(\"general.embed.description\")} />\n    <meta property=\"og:description\" content={$t(\"general.embed.description\")} />\n\n    {#if env.HOST}\n        <meta\n            property=\"og:url\"\n            content=\"https://{env.HOST}{$page.url.pathname}\"\n        />\n    {/if}\n\n    {#if device.is.mobile}\n        <meta\n            name=\"theme-color\"\n            content={statusBarColors.mobile[$currentTheme]}\n        />\n    {:else}\n        <meta\n            name=\"theme-color\"\n            content={statusBarColors.desktop[$currentTheme]}\n        />\n    {/if}\n\n    {#if plausibleLoaded || (browser && env.PLAUSIBLE_ENABLED && !$settings.privacy.disableAnalytics)}\n        <script\n            defer\n            data-domain={env.HOST}\n            on:load={() => {\n                plausibleLoaded = true;\n            }}\n            src=\"https://{env.PLAUSIBLE_HOST}/js/script.js\"\n        ></script>\n    {/if}\n</svelte:head>\n\n<div\n    style=\"display: contents\"\n    data-theme={browser ? $currentTheme : undefined}\n    lang={$locale}\n>\n    {#if preloadAssets}\n        <div id=\"preload\" aria-hidden=\"true\">??</div>\n    {/if}\n    <div\n        id=\"cobalt\"\n        class:loaded={browser}\n        data-chrome={device.browser.chrome}\n        data-iphone={device.is.iPhone}\n        data-mobile={device.is.mobile}\n        data-reduce-motion={reduceMotion}\n        data-reduce-transparency={reduceTransparency}\n    >\n        {#if device.is.iPhone && app.is.installed}\n            <NotchSticker />\n        {/if}\n        <DialogHolder />\n        <Sidebar />\n        {#if $updated}\n            <UpdateNotification />\n        {/if}\n        <ProcessingQueue />\n        <div id=\"content\">\n            {#if ($turnstileEnabled && $page.url.pathname === \"/\") || $turnstileCreated}\n                <Turnstile />\n            {/if}\n            <slot></slot>\n        </div>\n    </div>\n</div>\n\n<style>\n    #cobalt {\n        height: 100%;\n        width: 100%;\n        display: grid;\n        grid-template-columns:\n            calc(var(--sidebar-width) + var(--sidebar-inner-padding) * 2)\n            1fr;\n        overflow: hidden;\n        background-color: var(--sidebar-bg);\n        color: var(--secondary);\n        position: fixed;\n    }\n\n    /* add padding for notch / dynamic island in landscape */\n    @media screen and (orientation: landscape) and (min-width: 535px) {\n        #cobalt[data-iphone=\"true\"] {\n            grid-template-columns:\n                calc(\n                    var(--sidebar-width) + var(--sidebar-inner-padding) * 2 +\n                        env(safe-area-inset-left)\n                )\n                1fr;\n        }\n\n        #cobalt[data-iphone=\"true\"] #content {\n            padding-right: env(safe-area-inset-right);\n        }\n    }\n\n    #content {\n        display: flex;\n        overflow: scroll;\n        background-color: var(--primary);\n        box-shadow: 0 0 0 var(--content-border-thickness) var(--content-border);\n        margin-left: var(--content-border-thickness);\n    }\n\n    @media (display-mode: standalone) and (min-width: 535px)  {\n        [data-mobile=\"false\"] #content {\n            margin-top: var(--content-border-thickness);\n            border-top-left-radius: 8px;\n        }\n\n        [data-mobile=\"false\"] #content:dir(rtl) {\n            border-top-left-radius: 0;\n            border-top-right-radius: 8px;\n        }\n    }\n\n    #content:dir(rtl) {\n        margin-left: 0;\n        margin-right: var(--content-border-thickness);\n    }\n\n    @media screen and (max-width: 535px) {\n        /* dark navbar cuz it looks better on mobile */\n        :global([data-theme=\"light\"]) {\n            --sidebar-bg: #000000;\n            --sidebar-highlight: var(--primary);\n        }\n\n        #cobalt {\n            display: grid;\n            grid-template-columns: unset;\n            grid-template-rows:\n                1fr\n                calc(\n                    var(--sidebar-height-mobile) + var(--sidebar-inner-padding) * 2\n                );\n        }\n\n        #content,\n        #content:dir(rtl) {\n            padding-top: env(safe-area-inset-top);\n            order: -1;\n\n            margin: 0;\n            box-shadow: none;\n\n            border-bottom-left-radius: calc(var(--border-radius) * 2);\n            border-bottom-right-radius: calc(var(--border-radius) * 2);\n        }\n    }\n\n    /* preload assets to prevent flickering when they appear on screen */\n    #preload {\n        width: 0;\n        height: 0;\n        position: absolute;\n        z-index: -10;\n        content: url(/meowbalt/smile.png) url(/meowbalt/error.png)\n            url(/meowbalt/question.png) url(/meowbalt/think.png);\n\n        font-family: \"Noto Sans Mono\";\n        font-size: 0;\n        opacity: 0;\n\n        pointer-events: none;\n        user-select: none;\n        -webkit-user-select: none;\n        -webkit-user-drag: none;\n    }\n</style>\n"
  },
  {
    "path": "web/src/routes/+layout.ts",
    "content": "export const prerender = true;\nexport const ssr = true;\n\nimport { browser } from '$app/environment';\n\nimport { get } from 'svelte/store';\nimport type { Load } from '@sveltejs/kit';\n\nimport { loadTranslations, defaultLocale } from '$lib/i18n/translations';\n\nexport const load: Load = async ({ url }) => {\n    const { pathname } = url;\n\n    let preferredLocale = defaultLocale;\n\n    if (browser) {\n        preferredLocale = get((await import('$lib/i18n/locale')).default);\n    }\n\n    await loadTranslations(preferredLocale, pathname);\n    return {};\n}\n"
  },
  {
    "path": "web/src/routes/+page.svelte",
    "content": "<script>\n    import { t } from \"$lib/i18n/translations\";\n\n    import Omnibox from \"$components/save/Omnibox.svelte\";\n    import Meowbalt from \"$components/misc/Meowbalt.svelte\";\n    import SupportedServices from \"$components/save/SupportedServices.svelte\";\n</script>\n\n<svelte:head>\n    <title>{$t(\"general.cobalt\")}</title>\n    <meta property=\"og:title\" content={$t(\"general.cobalt\")} />\n</svelte:head>\n\n<div id=\"cobalt-save-container\" class=\"center-column-container\">\n    <SupportedServices />\n    <main\n        id=\"cobalt-save\"\n        tabindex=\"-1\"\n        data-first-focus\n    >\n        <Meowbalt emotion=\"smile\" />\n        <Omnibox />\n    </main>\n    <div id=\"terms-note\">\n        {$t(\"save.terms.note.agreement\")}\n        <a href=\"/about/terms\">{$t(\"save.terms.note.link\")}</a>\n    </div>\n</div>\n\n<style>\n    #cobalt-save-container {\n        padding: var(--padding);\n        overflow: hidden;\n    }\n\n    #cobalt-save {\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n        justify-content: center;\n        width: 100%;\n        height: 100%;\n        gap: 15px;\n    }\n\n    #terms-note {\n        bottom: 0;\n        color: var(--gray);\n        font-size: 12px;\n        text-align: center;\n        padding-bottom: 6px;\n        font-weight: 500;\n    }\n\n    @media screen and (max-width: 535px) {\n        #cobalt-save-container {\n            padding-top: calc(var(--padding) / 2);\n        }\n\n        #terms-note {\n            font-size: 11px;\n            padding-bottom: 0;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/routes/_headers/+server.ts",
    "content": "export function GET() {\n    const _headers = {\n        \"/*\": {\n            \"Cross-Origin-Opener-Policy\": \"same-origin\",\n            \"Cross-Origin-Embedder-Policy\": \"require-corp\",\n        }\n    }\n\n    return new Response(\n        Object.entries(_headers).map(\n            ([path, headers]) => [\n                path,\n                Object.entries(headers).map(\n                    ([key, value]) => `    ${key}: ${value}`\n                )\n            ].flat().join(\"\\n\")\n        ).join(\"\\n\\n\")\n    );\n}\n\nexport const prerender = true;\n"
  },
  {
    "path": "web/src/routes/about/+layout.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n\n    import PageNav from \"$components/subnav/PageNav.svelte\";\n\n    import PageNavTab from \"$components/subnav/PageNavTab.svelte\";\n    import PageNavSection from \"$components/subnav/PageNavSection.svelte\";\n\n    import IconLock from \"@tabler/icons-svelte/IconLock.svelte\";\n    import IconComet from \"@tabler/icons-svelte/IconComet.svelte\";\n    import IconChecklist from \"@tabler/icons-svelte/IconChecklist.svelte\";\n    import IconUsersGroup from \"@tabler/icons-svelte/IconUsersGroup.svelte\";\n    import IconHeartHandshake from \"@tabler/icons-svelte/IconHeartHandshake.svelte\";\n</script>\n\n<PageNav\n    pageName=\"about\"\n    homeNavPath=\"/about\"\n    homeTitle={$t(\"tabs.about\")}\n    contentPadding\n    wideContent\n>\n    <svelte:fragment slot=\"navigation\">\n        <PageNavSection>\n            <PageNavTab\n                path=\"/about/general\"\n                title={$t(\"about.page.general\")}\n                icon={IconComet}\n                iconColor=\"blue\"\n            />\n            <PageNavTab\n                path=\"/about/community\"\n                title={$t(\"about.page.community\")}\n                icon={IconUsersGroup}\n                iconColor=\"purple\"\n            />\n        </PageNavSection>\n\n        <PageNavSection>\n            <PageNavTab\n                path=\"/about/privacy\"\n                title={$t(\"about.page.privacy\")}\n                icon={IconLock}\n                iconColor=\"blue\"\n            />\n            <PageNavTab\n                path=\"/about/terms\"\n                title={$t(\"about.page.terms\")}\n                icon={IconChecklist}\n                iconColor=\"green\"\n            />\n            <PageNavTab\n                path=\"/about/credits\"\n                title={$t(\"about.page.credits\")}\n                icon={IconHeartHandshake}\n                iconColor=\"magenta\"\n            />\n        </PageNavSection>\n    </svelte:fragment>\n\n    <slot slot=\"content\"></slot>\n</PageNav>\n"
  },
  {
    "path": "web/src/routes/about/+page.svelte",
    "content": "<!--\n    please don't remove this file\n    it's used to display page navigation on mobile, without a blank page it won't work\n-->\n"
  },
  {
    "path": "web/src/routes/about/[page]/+page.svelte",
    "content": "<script lang=\"ts\">\n\timport type { PageData } from './$types';\n\texport let data: PageData;\n</script>\n\n<svelte:component this={data.component} />\n"
  },
  {
    "path": "web/src/routes/about/[page]/+page.ts",
    "content": "import locale from \"$lib/i18n/locale\";\nimport { get } from \"svelte/store\";\nimport { error } from \"@sveltejs/kit\";\nimport { defaultLocale } from \"$lib/i18n/translations\";\n\nimport type { Component } from \"svelte\";\nimport type { PageLoad } from \"./$types\";\nimport type { DefaultImport } from \"$lib/types/generic\";\n\nconst pages = import.meta.glob(\"$i18n/*/about/*.md\");\n\nexport const load: PageLoad = async ({ params }) => {\n    const getPage = (locale: string) => Object.keys(pages).find(\n        file => file.endsWith(`${locale}/about/${params.page}.md`)\n    );\n\n    const componentPath = getPage(get(locale)) || getPage(defaultLocale);\n    if (componentPath) {\n        const componentImport = pages[componentPath] as DefaultImport<Component>;\n        return { component: (await componentImport()).default }\n    }\n\n    error(404, 'Not found');\n};\n\nexport const prerender = true;\n"
  },
  {
    "path": "web/src/routes/about/community/+page.svelte",
    "content": "<script lang=\"ts\">\n    import locale from \"$lib/i18n/locale\";\n\n    import { contacts } from \"$lib/env\";\n    import { t } from \"$lib/i18n/translations\";\n\n    import AboutSupport from \"$components/about/AboutSupport.svelte\";\n\n    let buttonContainerWidth: number;\n</script>\n\n<div id=\"support-page\">\n    <div\n        id=\"support-buttons\"\n        bind:offsetWidth={buttonContainerWidth}\n\n        class=\"two\"\n        class:one={buttonContainerWidth < 500}\n    >\n        <AboutSupport\n            platform=\"github\"\n            externalLink={contacts.github}\n        />\n\n        {#if $locale === \"ru\"}\n            <AboutSupport\n                platform=\"telegram\"\n                externalLink={contacts.telegram_ru}\n            />\n        {:else}\n            <AboutSupport\n                platform=\"discord\"\n                externalLink={contacts.discord}\n            />\n            <AboutSupport\n                platform=\"twitter\"\n                externalLink={contacts.twitter}\n            />\n            <AboutSupport\n                platform=\"bluesky\"\n                externalLink={contacts.bluesky}\n            />\n        {/if}\n    </div>\n\n    <div class=\"subtext support-note\">\n        {$t(\"about.support.description.issue\")}\n\n        {#if $locale !== \"ru\"}\n            {$t(\"about.support.description.help\")}\n        {/if}\n\n        {$t(\"about.support.description.best-effort\")}\n    </div>\n</div>\n\n<style>\n    #support-page {\n        display: flex;\n        flex-direction: column;\n        gap: 18px;\n    }\n\n    #support-buttons {\n        display: grid;\n        grid-template-columns: 1fr 1fr 1fr;\n        overflow-x: scroll;\n        gap: var(--padding);\n    }\n\n    #support-buttons.two {\n        grid-template-columns: 1fr 1fr;\n    }\n\n    #support-buttons.one {\n        grid-template-columns: 1fr;\n    }\n\n    .support-note {\n        text-align: start;\n        padding: 0;\n    }\n</style>\n"
  },
  {
    "path": "web/src/routes/donate/+page.svelte",
    "content": "<script lang=\"ts\">\n    import \"@fontsource/redaction-10/400.css\";\n\n    import { donate } from \"$lib/env\";\n    import { t } from \"$lib/i18n/translations\";\n\n    import DonateBanner from \"$components/donate/DonateBanner.svelte\";\n    import DonateAltItem from \"$components/donate/DonateAltItem.svelte\";\n    import DonateShareCard from \"$components/donate/DonateShareCard.svelte\";\n    import DonateOptionsCard from \"$components/donate/DonateOptionsCard.svelte\";\n\n    import IconDiamond from \"@tabler/icons-svelte/IconDiamond.svelte\";\n</script>\n\n<svelte:head>\n    <title>\n        {$t(\"tabs.donate\")} ~ {$t(\"general.cobalt\")}\n    </title>\n    <meta\n        property=\"og:title\"\n        content=\"{$t(\"tabs.donate\")} ~ {$t(\"general.cobalt\")}\"\n    />\n</svelte:head>\n\n<div id=\"donate-page-wrapper\">\n    <main id=\"donate-page\">\n        <DonateBanner />\n\n        <section id=\"support-options\">\n            <DonateOptionsCard />\n            <DonateShareCard />\n        </section>\n\n        <section id=\"motivation\" class=\"long-text\">\n            <p>{$t(\"donate.body.motivation\")}</p>\n            <p>{$t(\"donate.body.no_bullshit\")}</p>\n            <p>{$t(\"donate.body.keep_going\")}</p>\n        </section>\n\n        <section id=\"crypto\">\n            <div id=\"crypto-section-header\">\n                <IconDiamond />\n                <h3 id=\"crypto-title\">{$t(\"donate.alternative.title\")}</h3>\n            </div>\n            <div id=\"wallet-grid\">\n                {#each Object.entries(donate.crypto) as [name, address]}\n                    <DonateAltItem type=\"copy\" {name} {address} />\n                {/each}\n                {#each Object.entries(donate.other) as [name, address]}\n                    <DonateAltItem type=\"open\" {name} {address} />\n                {/each}\n            </div>\n        </section>\n    </main>\n</div>\n\n<style>\n    #donate-page-wrapper {\n        display: flex;\n        width: 100%;\n        height: max-content;\n        justify-content: center;\n        overflow-y: scroll;\n        overflow-x: hidden;\n        padding: var(--padding);\n    }\n\n    #donate-page {\n        --donate-border-radius: 24px;\n        --donate-border-opacity: 0.1;\n        --donate-gradient-start: #1a1a1a;\n        --donate-gradient-end: #404040;\n\n        max-width: 100%;\n        width: 900px;\n\n        display: flex;\n        flex-direction: column;\n        gap: 15px;\n    }\n\n    :global([data-theme=\"dark\"]) #donate-page {\n        --donate-border-opacity: 0.05;\n        --donate-gradient-start: #111111;\n        --donate-gradient-end: #2a2a2a;\n    }\n\n    #support-options {\n        display: flex;\n        flex-direction: row;\n        gap: 15px;\n        width: 100%;\n    }\n\n    #motivation,\n    #crypto {\n        padding: 0 12px;\n    }\n\n    #crypto {\n        margin-bottom: 12px;\n    }\n\n    #crypto-section-header {\n        display: flex;\n        align-items: center;\n        gap: 8px;\n        margin-bottom: 14px;\n    }\n\n    #crypto-section-header :global(svg) {\n        width: 22px;\n        height: 22px;\n        stroke-width: 1.8px;\n    }\n\n    #wallet-grid {\n        display: grid;\n        grid-template-columns: 1fr 1fr;\n        gap: 10px;\n    }\n\n    #motivation p:first-child {\n        margin-block-start: 10px;\n    }\n\n    @media screen and (max-width: 760px) {\n        #support-options {\n            flex-direction: column;\n        }\n\n        #wallet-grid {\n            grid-template-columns: 1fr;\n        }\n\n        #motivation,\n        #crypto {\n            padding: 0 6px;\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/routes/remux/+page.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n    import { createRemuxPipeline } from \"$lib/task-manager/queue\";\n\n    import DropReceiver from \"$components/misc/DropReceiver.svelte\";\n    import FileReceiver from \"$components/misc/FileReceiver.svelte\";\n    import BulletExplain from \"$components/misc/BulletExplain.svelte\";\n\n    import IconRepeat from \"@tabler/icons-svelte/IconRepeat.svelte\";\n    import IconDevices from \"@tabler/icons-svelte/IconDevices.svelte\";\n    import IconInfoCircle from \"@tabler/icons-svelte/IconInfoCircle.svelte\";\n\n    let draggedOver = false;\n    let files: FileList | undefined;\n\n    const remux = async () => {\n        if (!files) return;\n\n        for (let i = 0; i < files?.length; i++) {\n            const type = files[i].type;\n            // TODO: stricter type limits?\n            if (type.startsWith(\"video/\") || type.startsWith(\"audio/\")) {\n                createRemuxPipeline(files[i]);\n            }\n        }\n\n        files = undefined;\n    };\n</script>\n\n<svelte:head>\n    <title>{$t(\"tabs.remux\")} ~ {$t(\"general.cobalt\")}</title>\n    <meta\n        property=\"og:title\"\n        content=\"{$t('tabs.remux')} ~ {$t('general.cobalt')}\"\n    />\n</svelte:head>\n\n<DropReceiver bind:files bind:draggedOver onDrop={remux} id=\"remux-container\">\n    <div id=\"remux-open\" tabindex=\"-1\" data-first-focus>\n        <div id=\"remux-receiver\">\n            <FileReceiver\n                bind:draggedOver\n                bind:files\n                onImport={remux}\n                acceptTypes={[\"video/*\", \"audio/*\"]}\n                acceptExtensions={[\n                    \"mp4\",\n                    \"webm\",\n                    \"mp3\",\n                    \"ogg\",\n                    \"opus\",\n                    \"wav\",\n                    \"m4a\",\n                ]}\n            />\n        </div>\n\n        <div id=\"remux-bullets\">\n            <BulletExplain\n                title={$t(\"remux.bullet.purpose.title\")}\n                description={$t(\"remux.bullet.purpose.description\")}\n                icon={IconRepeat}\n            />\n\n            <BulletExplain\n                title={$t(\"remux.bullet.explainer.title\")}\n                description={$t(\"remux.bullet.explainer.description\")}\n                icon={IconInfoCircle}\n            />\n\n            <BulletExplain\n                title={$t(\"remux.bullet.privacy.title\")}\n                description={$t(\"remux.bullet.privacy.description\")}\n                icon={IconDevices}\n            />\n        </div>\n    </div>\n</DropReceiver>\n\n<style>\n    :global(#remux-container) {\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        width: 100%;\n    }\n\n    #remux-open {\n        display: flex;\n        flex-direction: row;\n        justify-content: center;\n        align-items: center;\n        text-align: center;\n        gap: 48px;\n    }\n\n    #remux-receiver {\n        max-width: 450px;\n        display: flex;\n        flex-direction: column;\n        gap: var(--padding);\n    }\n\n    #remux-bullets {\n        display: flex;\n        flex-direction: column;\n        gap: 18px;\n        max-width: 450px;\n    }\n\n    @media screen and (max-width: 920px) {\n        #remux-open {\n            flex-direction: column;\n            gap: var(--padding);\n        }\n\n        #remux-bullets {\n            padding: var(--padding);\n        }\n    }\n\n    @media screen and (max-width: 535px) {\n        #remux-bullets {\n            gap: var(--padding);\n        }\n    }\n\n    @media screen and (max-height: 750px) and (max-width: 535px) {\n        :global(#remux-container:not(.processing)) {\n            justify-content: start;\n            align-items: start;\n            padding-top: var(--padding);\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/routes/settings/+layout.svelte",
    "content": "<script lang=\"ts\">\n    import settings from \"$lib/state/settings\";\n\n    import { version } from \"$lib/version\";\n    import { t } from \"$lib/i18n/translations\";\n\n    import PageNav from \"$components/subnav/PageNav.svelte\";\n\n    import PageNavTab from \"$components/subnav/PageNavTab.svelte\";\n    import PageNavSection from \"$components/subnav/PageNavSection.svelte\";\n\n    import IconLock from \"@tabler/icons-svelte/IconLock.svelte\";\n    import IconSunHigh from \"@tabler/icons-svelte/IconSunHigh.svelte\";\n    import IconAccessible from \"@tabler/icons-svelte/IconAccessible.svelte\";\n\n    import IconMovie from \"@tabler/icons-svelte/IconMovie.svelte\";\n    import IconMusic from \"@tabler/icons-svelte/IconMusic.svelte\";\n    import IconFileDownload from \"@tabler/icons-svelte/IconFileDownload.svelte\";\n\n    import IconCpu from \"@tabler/icons-svelte/IconCpu.svelte\";\n    import IconWorld from \"@tabler/icons-svelte/IconWorld.svelte\";\n\n    import IconBug from \"@tabler/icons-svelte/IconBug.svelte\";\n    import IconAdjustmentsStar from \"@tabler/icons-svelte/IconAdjustmentsStar.svelte\";\n\n    $: versionText = $version\n        ? `v${$version.version}-${$version.commit.slice(0, 8)}`\n        : \"\\xa0\";\n</script>\n\n<PageNav\n    pageName=\"settings\"\n    pageSubtitle={versionText}\n    homeNavPath=\"/settings\"\n    homeTitle={$t(\"tabs.settings\")}\n>\n    <svelte:fragment slot=\"navigation\">\n        <PageNavSection>\n            <PageNavTab\n                path=\"/settings/appearance\"\n                title={$t(\"settings.page.appearance\")}\n                icon={IconSunHigh}\n                iconColor=\"blue\"\n            />\n            <PageNavTab\n                path=\"/settings/accessibility\"\n                title={$t(\"settings.page.accessibility\")}\n                icon={IconAccessible}\n                iconColor=\"purple\"\n            />\n        </PageNavSection>\n\n        <PageNavSection>\n            <PageNavTab\n                path=\"/settings/video\"\n                title={$t(\"settings.page.video\")}\n                icon={IconMovie}\n                iconColor=\"magenta\"\n            />\n            <PageNavTab\n                path=\"/settings/audio\"\n                title={$t(\"settings.page.audio\")}\n                icon={IconMusic}\n                iconColor=\"orange\"\n            />\n            <PageNavTab\n                path=\"/settings/metadata\"\n                title={$t(\"settings.page.metadata\")}\n                icon={IconFileDownload}\n                iconColor=\"green\"\n            />\n        </PageNavSection>\n\n        <PageNavSection>\n            <PageNavTab\n                path=\"/settings/local\"\n                title={$t(\"settings.page.local\")}\n                icon={IconCpu}\n                iconColor=\"blue\"\n            />\n            <PageNavTab\n                path=\"/settings/instances\"\n                title={$t(\"settings.page.instances\")}\n                icon={IconWorld}\n                iconColor=\"purple\"\n            />\n        </PageNavSection>\n\n        <PageNavSection>\n            <PageNavTab\n                path=\"/settings/privacy\"\n                title={$t(\"settings.page.privacy\")}\n                icon={IconLock}\n                iconColor=\"gray\"\n            />\n            <PageNavTab\n                path=\"/settings/advanced\"\n                title={$t(\"settings.page.advanced\")}\n                icon={IconAdjustmentsStar}\n            />\n            {#if $settings.advanced.debug}\n                <PageNavTab\n                    path=\"/settings/debug\"\n                    title={$t(\"settings.page.debug\")}\n                    icon={IconBug}\n                />\n            {/if}\n        </PageNavSection>\n    </svelte:fragment>\n\n    <slot slot=\"content\"></slot>\n</PageNav>\n"
  },
  {
    "path": "web/src/routes/settings/+page.svelte",
    "content": "<!--\n    please don't remove this file\n    it's used to display page navigation on mobile, without a blank page it won't work\n-->\n"
  },
  {
    "path": "web/src/routes/settings/accessibility/+page.svelte",
    "content": "<script lang=\"ts\">\n    import { device } from \"$lib/device\";\n    import { t } from \"$lib/i18n/translations\";\n\n    import SettingsToggle from \"$components/buttons/SettingsToggle.svelte\";\n    import SettingsCategory from \"$components/settings/SettingsCategory.svelte\";\n</script>\n\n<SettingsCategory\n    sectionId=\"visual\"\n    title={$t(\"settings.accessibility.visual\")}\n>\n    <SettingsToggle\n        settingContext=\"accessibility\"\n        settingId=\"reduceMotion\"\n        title={$t(\"settings.accessibility.motion.title\")}\n        description={$t(\"settings.accessibility.motion.description\")}\n    />\n    <SettingsToggle\n        settingContext=\"accessibility\"\n        settingId=\"reduceTransparency\"\n        title={$t(\"settings.accessibility.transparency.title\")}\n        description={$t(\"settings.accessibility.transparency.description\")}\n    />\n</SettingsCategory>\n\n<SettingsCategory\n    sectionId=\"behavior\"\n    title={$t(\"settings.accessibility.behavior\")}\n>\n    <SettingsToggle\n        settingContext=\"accessibility\"\n        settingId=\"dontAutoOpenQueue\"\n        title={$t(\"settings.accessibility.auto_queue.title\")}\n        description={$t(\"settings.accessibility.auto_queue.description\")}\n    />\n</SettingsCategory>\n\n{#if device.supports.haptics}\n    <SettingsCategory\n        sectionId=\"haptics\"\n        title={$t(\"settings.accessibility.haptics\")}\n    >\n        <SettingsToggle\n            settingContext=\"accessibility\"\n            settingId=\"disableHaptics\"\n            title={$t(\"settings.accessibility.haptics.title\")}\n            description={$t(\"settings.accessibility.haptics.description\")}\n        />\n    </SettingsCategory>\n{/if}\n"
  },
  {
    "path": "web/src/routes/settings/advanced/+page.svelte",
    "content": "<script lang=\"ts\">\n    import { t } from \"$lib/i18n/translations\";\n\n    import SettingsToggle from \"$components/buttons/SettingsToggle.svelte\";\n    import ManageSettings from \"$components/settings/ManageSettings.svelte\";\n    import SettingsCategory from \"$components/settings/SettingsCategory.svelte\";\n    import ClearStorageButton from \"$components/settings/ClearStorageButton.svelte\";\n</script>\n\n<SettingsCategory sectionId=\"debug\" title={$t(\"settings.advanced.debug\")}>\n    <SettingsToggle\n        settingContext=\"advanced\"\n        settingId=\"debug\"\n        title={$t(\"settings.advanced.debug.title\")}\n        description={$t(\"settings.advanced.debug.description\")}\n    />\n</SettingsCategory>\n\n<SettingsCategory sectionId=\"settings-data\" title={$t(\"settings.advanced.settings_data\")}>\n    <ManageSettings />\n</SettingsCategory>\n\n<SettingsCategory sectionId=\"local-storage\" title={$t(\"settings.advanced.local_storage\")}>\n    <ClearStorageButton />\n</SettingsCategory>\n"
  },
  {
    "path": "web/src/routes/settings/appearance/+page.svelte",
    "content": "<script lang=\"ts\">\n    import settings from \"$lib/state/settings\";\n\n    import { device } from \"$lib/device\";\n    import { themeOptions } from \"$lib/types/settings\";\n    import { t, locales } from \"$lib/i18n/translations\";\n\n    import Switcher from \"$components/buttons/Switcher.svelte\";\n    import SettingsButton from \"$components/buttons/SettingsButton.svelte\";\n    import SettingsToggle from \"$components/buttons/SettingsToggle.svelte\";\n    import SettingsCategory from \"$components/settings/SettingsCategory.svelte\";\n    import SettingsDropdown from \"$components/settings/SettingsDropdown.svelte\";\n\n    const dropdownItems = () => {\n        return $locales.reduce((obj, lang) => {\n            return {\n                ...obj,\n                [lang]: $t(`languages.${lang}`),\n            };\n        }, {});\n    };\n</script>\n\n<SettingsCategory sectionId=\"theme\" title={$t(\"settings.theme\")}>\n    <Switcher big={true} description={$t(\"settings.theme.description\")}>\n        {#each themeOptions as value}\n            <SettingsButton\n                settingContext=\"appearance\"\n                settingId=\"theme\"\n                settingValue={value}\n            >\n                {$t(`settings.theme.${value}`)}\n            </SettingsButton>\n        {/each}\n    </Switcher>\n</SettingsCategory>\n\n<SettingsCategory sectionId=\"language\" title={$t(\"settings.language\")}>\n    <SettingsToggle\n        settingContext=\"appearance\"\n        settingId=\"autoLanguage\"\n        title={$t(\"settings.language.auto.title\")}\n        description={$t(\"settings.language.auto.description\")}\n    />\n\n    <SettingsDropdown\n        title={$t(\"settings.language.preferred.title\")}\n        description={$t(\"settings.language.preferred.description\")}\n        items={dropdownItems()}\n        settingContext=\"appearance\"\n        settingId=\"language\"\n        selectedOption={$settings.appearance.language}\n        selectedTitle={$t(`languages.${$settings.appearance.language}`)}\n        disabled={$settings.appearance.autoLanguage}\n    />\n</SettingsCategory>\n\n{#if device.is.mobile}\n    <SettingsCategory sectionId=\"tabs\" title={$t(\"settings.tabs\")}>\n        <SettingsToggle\n            settingContext=\"appearance\"\n            settingId=\"hideRemuxTab\"\n            title={$t(\"settings.tabs.hide_remux\")}\n            description={$t(\"settings.tabs.hide_remux.description\")}\n        />\n    </SettingsCategory>\n{/if}\n"
  },
  {
    "path": "web/src/routes/settings/audio/+page.svelte",
    "content": "<script lang=\"ts\">\n    import settings from \"$lib/state/settings\";\n    import { t } from \"$lib/i18n/translations\";\n    import { namedYoutubeDubLanguages } from \"$lib/settings/audio-sub-language\";\n\n    import { audioFormatOptions, audioBitrateOptions } from \"$lib/types/settings\";\n\n    import SettingsCategory from \"$components/settings/SettingsCategory.svelte\";\n    import Switcher from \"$components/buttons/Switcher.svelte\";\n    import SettingsButton from \"$components/buttons/SettingsButton.svelte\";\n    import SettingsToggle from \"$components/buttons/SettingsToggle.svelte\";\n    import SettingsDropdown from \"$components/settings/SettingsDropdown.svelte\";\n\n    const displayLangs = namedYoutubeDubLanguages($t);\n</script>\n\n<SettingsCategory sectionId=\"format\" title={$t(\"settings.audio.format\")}>\n    <Switcher big={true} description={$t(\"settings.audio.format.description\")}>\n        {#each audioFormatOptions as value}\n            <SettingsButton\n                settingContext=\"save\"\n                settingId=\"audioFormat\"\n                settingValue={value}\n            >\n                {$t(`settings.audio.format.${value}`)}\n            </SettingsButton>\n        {/each}\n    </Switcher>\n</SettingsCategory>\n\n<SettingsCategory\n    sectionId=\"bitrate\"\n    title={$t(\"settings.audio.bitrate\")}\n    disabled={[\"wav\", \"best\"].includes($settings.save.audioFormat)}\n>\n    <Switcher big={true} description={$t(\"settings.audio.bitrate.description\")}>\n        {#each audioBitrateOptions as value}\n            <SettingsButton\n                settingContext=\"save\"\n                settingId=\"audioBitrate\"\n                settingValue={value}\n            >\n                {value}{$t(\"settings.audio.bitrate.kbps\")}\n            </SettingsButton>\n        {/each}\n    </Switcher>\n</SettingsCategory>\n\n<SettingsCategory\n    sectionId=\"youtube-better-audio\"\n    title={$t(\"settings.audio.youtube.better_audio\")}\n>\n    <SettingsToggle\n        settingContext=\"save\"\n        settingId=\"youtubeBetterAudio\"\n        title={$t(\"settings.audio.youtube.better_audio.title\")}\n        description={$t(\"settings.audio.youtube.better_audio.description\")}\n    />\n</SettingsCategory>\n\n<SettingsCategory\n    sectionId=\"youtube-dub\"\n    title={$t(\"settings.audio.youtube.dub\")}\n>\n    <SettingsDropdown\n        title={$t(\"settings.audio.youtube.dub.title\")}\n        description={$t(\"settings.audio.youtube.dub.description\")}\n        items={displayLangs}\n        settingContext=\"save\"\n        settingId=\"youtubeDubLang\"\n        selectedOption={$settings.save.youtubeDubLang}\n        selectedTitle={displayLangs[$settings.save.youtubeDubLang]}\n    />\n</SettingsCategory>\n\n<SettingsCategory\n    sectionId=\"tiktok\"\n    title={$t(\"settings.audio.tiktok.original\")}\n>\n    <SettingsToggle\n        settingContext=\"save\"\n        settingId=\"tiktokFullAudio\"\n        title={$t(\"settings.audio.tiktok.original.title\")}\n        description={$t(\"settings.audio.tiktok.original.description\")}\n    />\n</SettingsCategory>\n"
  },
  {
    "path": "web/src/routes/settings/debug/+page.svelte",
    "content": "<script lang=\"ts\">\n    import { onDestroy, onMount } from \"svelte\";\n    import { goto } from \"$app/navigation\";\n    import { version } from \"$lib/version\";\n    import { device, app } from \"$lib/device\";\n    import { defaultNavPage } from \"$lib/subnav\";\n    import settings, { storedSettings } from \"$lib/state/settings\";\n    import SectionHeading from \"$components/misc/SectionHeading.svelte\";\n    import { type Readable, type Unsubscriber } from \"svelte/store\";\n\n    const stateSubscribers: Record<string, Unsubscriber> = {};\n    let states: Record<string, unknown> = {};\n\n    $: sections = [\n        { title: \"device\", data: device },\n        { title: \"app\", data: app },\n        { title: \"settings\", data: $storedSettings },\n        { title: \"version\", data: $version },\n        { title: \"states\", data: states }\n    ];\n\n    const loadStates = () => {\n        const modules = import.meta.glob(\"/src/lib/*/*.ts\");\n        const excluded = new Set(['translations.translations', 'settings']);\n\n        Object.entries(modules).map(async ([ name, _import ]) => {\n            const moduleName = name.split('/').pop()?.split('.').shift();\n\n            const module = await _import() as Record<string, unknown>;\n            for (const key in module) {\n                const _export = module[key] as unknown as Readable<unknown>;\n                if (typeof _export === 'object' && 'subscribe' in _export) {\n                    const name = moduleName + (key === 'default' ? '' : `.${key}`);\n                    if (excluded.has(name)) continue;\n\n                    stateSubscribers[name] = _export.subscribe((value) => {\n                        states = {\n                            ...states,\n                            [name]: value\n                        }\n                    });\n                }\n            }\n        });\n    }\n\n    onMount(() => {\n        if (!$settings.advanced.debug) {\n            goto(defaultNavPage(\"settings\"), { replaceState: true });\n        }\n\n        loadStates();\n    });\n\n    onDestroy(() => {\n        Object.values(stateSubscribers).map(unsub => unsub());\n    })\n</script>\n\n{#if $settings.advanced.debug}\n    <div id=\"debug-page\">\n        {#each sections as { title, data }, i}\n            <div class=\"debug-section\">\n                <SectionHeading\n                    sectionId={title}\n                    {title}\n                    copyData={JSON.stringify(data)}\n                />\n                <div class=\"json-block subtext\">\n                    {JSON.stringify(data, null, 2)}\n                </div>\n            </div>\n        {/each}\n    </div>\n{/if}\n\n<style>\n    #debug-page {\n        display: flex;\n        flex-direction: column;\n        padding: calc(var(--subnav-padding) / 2);\n        gap: var(--padding);\n    }\n\n    .debug-section {\n        display: flex;\n        flex-direction: column;\n        gap: var(--padding);\n    }\n\n    .json-block {\n        display: flex;\n        flex-direction: column;\n        line-break: anywhere;\n        border-radius: var(--border-radius);\n        background: var(--button);\n        padding: var(--padding);\n        white-space: pre-wrap;\n    }\n</style>\n"
  },
  {
    "path": "web/src/routes/settings/instances/+page.svelte",
    "content": "<script lang=\"ts\">\n    import settings from \"$lib/state/settings\";\n\n    import { t } from \"$lib/i18n/translations\";\n\n    import SettingsInput from \"$components/settings/SettingsInput.svelte\";\n    import SettingsToggle from \"$components/buttons/SettingsToggle.svelte\";\n    import SettingsCategory from \"$components/settings/SettingsCategory.svelte\";\n</script>\n\n<SettingsCategory\n    sectionId=\"community\"\n    title={$t(\"settings.processing.community\")}\n>\n    <div class=\"category-inside-group\">\n        <SettingsToggle\n            settingContext=\"processing\"\n            settingId=\"enableCustomInstances\"\n            title={$t(\"settings.processing.enable_custom.title\")}\n        />\n        {#if $settings.processing.enableCustomInstances}\n            <SettingsInput\n                settingContext=\"processing\"\n                settingId=\"customInstanceURL\"\n                placeholder=\"https://instance.url.example/\"\n                showInstanceWarning\n                altText={$t(\"settings.processing.custom_instance.input.alt_text\")}\n            />\n        {/if}\n    </div>\n    <div class=\"subtext\">\n        {$t(\"settings.processing.enable_custom.description\")}\n    </div>\n</SettingsCategory>\n\n<SettingsCategory\n    sectionId=\"access-key\"\n    title={$t(\"settings.processing.access_key\")}\n>\n    <div class=\"category-inside-group\">\n        <SettingsToggle\n            settingContext=\"processing\"\n            settingId=\"enableCustomApiKey\"\n            title={$t(\"settings.processing.access_key.title\")}\n        />\n        {#if $settings.processing.enableCustomApiKey}\n            <SettingsInput\n                settingContext=\"processing\"\n                settingId=\"customApiKey\"\n                placeholder=\"00000000-0000-0000-0000-000000000000\"\n                altText={$t(\"settings.processing.access_key.input.alt_text\")}\n                type=\"uuid\"\n                sensitive\n            />\n        {/if}\n    </div>\n    <div class=\"subtext\">\n        {$t(\"settings.processing.access_key.description\")}\n    </div>\n</SettingsCategory>\n\n<style>\n    .category-inside-group {\n        display: flex;\n        flex-direction: column;\n        gap: 6px;\n    }\n\n    .subtext {\n        margin-top: -3px;\n    }\n</style>\n"
  },
  {
    "path": "web/src/routes/settings/local/+page.svelte",
    "content": "<script lang=\"ts\">\n    import env from \"$lib/env\";\n    import { t } from \"$lib/i18n/translations\";\n    import { localProcessingOptions } from \"$lib/types/settings\";\n\n    import Switcher from \"$components/buttons/Switcher.svelte\";\n    import SettingsButton from \"$components/buttons/SettingsButton.svelte\";\n    import SettingsToggle from \"$components/buttons/SettingsToggle.svelte\";\n    import SettingsCategory from \"$components/settings/SettingsCategory.svelte\";\n</script>\n\n<SettingsCategory sectionId=\"media-processing\" title={$t(\"settings.local.saving\")} beta>\n    <Switcher big={true} description={$t(\"settings.local.saving.description\")}>\n        {#each localProcessingOptions as value}\n            <SettingsButton\n                settingContext=\"save\"\n                settingId=\"localProcessing\"\n                settingValue={value}\n            >\n                {$t(`settings.local.saving.${value}`)}\n            </SettingsButton>\n        {/each}\n    </Switcher>\n</SettingsCategory>\n\n{#if env.ENABLE_WEBCODECS}\n    <SettingsCategory sectionId=\"webcodecs\" title={$t(\"settings.local.webcodecs\")} beta>\n        <SettingsToggle\n            settingContext=\"advanced\"\n            settingId=\"useWebCodecs\"\n            title={$t(\"settings.local.webcodecs.title\")}\n            description={$t(\"settings.local.webcodecs.description\")}\n        />\n    </SettingsCategory>\n{/if}\n"
  },
  {
    "path": "web/src/routes/settings/metadata/+page.svelte",
    "content": "<script lang=\"ts\">\n    import settings from \"$lib/state/settings\";\n    import { t } from \"$lib/i18n/translations\";\n\n    import { namedSubtitleLanguages } from \"$lib/settings/audio-sub-language\";\n    import { filenameStyleOptions, savingMethodOptions } from \"$lib/types/settings\";\n\n    import SettingsCategory from \"$components/settings/SettingsCategory.svelte\";\n    import Switcher from \"$components/buttons/Switcher.svelte\";\n    import SettingsButton from \"$components/buttons/SettingsButton.svelte\";\n    import SettingsToggle from \"$components/buttons/SettingsToggle.svelte\";\n    import FilenamePreview from \"$components/settings/FilenamePreview.svelte\";\n    import SettingsDropdown from \"$components/settings/SettingsDropdown.svelte\";\n\n    const displayLangs = namedSubtitleLanguages($t);\n</script>\n\n<SettingsCategory sectionId=\"filename\" title={$t(\"settings.metadata.filename\")}>\n    <div class=\"category-inside-group\">\n        <Switcher big={true}>\n            {#each filenameStyleOptions as value}\n                <SettingsButton\n                    settingContext=\"save\"\n                    settingId=\"filenameStyle\"\n                    settingValue={value}\n                >\n                    {$t(`settings.metadata.filename.${value}`)}\n                </SettingsButton>\n            {/each}\n        </Switcher>\n        <FilenamePreview />\n    </div>\n    <div class=\"subtext\">\n        {$t(\"settings.metadata.filename.description\")}\n    </div>\n</SettingsCategory>\n\n<SettingsCategory sectionId=\"saving\" title={$t(\"settings.saving.title\")}>\n    <Switcher big={true} description={$t(\"settings.saving.description\")}>\n        {#each savingMethodOptions as value}\n            <SettingsButton\n                settingContext=\"save\"\n                settingId=\"savingMethod\"\n                settingValue={value}\n            >\n                {$t(`settings.saving.${value}`)}\n            </SettingsButton>\n        {/each}\n    </Switcher>\n</SettingsCategory>\n\n<SettingsCategory\n    sectionId=\"subtitles\"\n    title={$t(\"settings.subtitles\")}\n>\n    <SettingsDropdown\n        title={$t(\"settings.subtitles.title\")}\n        description={$t(\"settings.subtitles.description\")}\n        items={displayLangs}\n        settingContext=\"save\"\n        settingId=\"subtitleLang\"\n        selectedOption={$settings.save.subtitleLang}\n        selectedTitle={displayLangs[$settings.save.subtitleLang]}\n    />\n</SettingsCategory>\n\n<SettingsCategory\n    sectionId=\"metadata\"\n    title={$t(\"settings.metadata.file\")}\n>\n    <SettingsToggle\n        settingContext=\"save\"\n        settingId=\"disableMetadata\"\n        title={$t(\"settings.metadata.disable.title\")}\n        description={$t(\"settings.metadata.disable.description\")}\n    />\n</SettingsCategory>\n\n<style>\n    .category-inside-group {\n        display: flex;\n        flex-direction: column;\n        gap: 6px;\n    }\n\n    .subtext {\n        margin-top: -3px;\n    }\n</style>\n"
  },
  {
    "path": "web/src/routes/settings/privacy/+page.svelte",
    "content": "<script lang=\"ts\">\n    import env from \"$lib/env\";\n    import { t } from \"$lib/i18n/translations\";\n\n    import OuterLink from \"$components/misc/OuterLink.svelte\";\n    import SettingsToggle from \"$components/buttons/SettingsToggle.svelte\";\n    import SettingsCategory from \"$components/settings/SettingsCategory.svelte\";\n</script>\n\n<SettingsCategory sectionId=\"tunnel\" title={$t(\"settings.privacy.tunnel\")}>\n    <SettingsToggle\n        settingContext=\"save\"\n        settingId=\"alwaysProxy\"\n        title={$t(\"settings.privacy.tunnel.title\")}\n        description={$t(\"settings.privacy.tunnel.description\")}\n    />\n</SettingsCategory>\n\n{#if env.PLAUSIBLE_ENABLED}\n    <SettingsCategory sectionId=\"analytics\" title={$t(\"settings.privacy.analytics\")}>\n        <SettingsToggle\n            settingContext=\"privacy\"\n            settingId=\"disableAnalytics\"\n            title={$t(\"settings.privacy.analytics.title\")}\n            description={$t(\"settings.privacy.analytics.description\")}\n        />\n        <div class=\"subtext learn-more-plausible\">\n            <OuterLink href=\"https://plausible.io/privacy-focused-web-analytics\">\n                {$t(\"settings.privacy.analytics.learnmore\")}\n            </OuterLink>\n        </div>\n    </SettingsCategory>\n{/if}\n\n<style>\n    .learn-more-plausible {\n        padding-top: 6px;\n    }\n</style>\n"
  },
  {
    "path": "web/src/routes/settings/video/+page.svelte",
    "content": "<script lang=\"ts\">\n    import env from \"$lib/env\";\n    import settings from \"$lib/state/settings\";\n    import { t } from \"$lib/i18n/translations\";\n\n    import { videoQualityOptions, youtubeVideoContainerOptions } from \"$lib/types/settings\";\n    import { youtubeVideoCodecOptions } from \"$lib/types/settings\";\n\n    import SettingsCategory from \"$components/settings/SettingsCategory.svelte\";\n    import Switcher from \"$components/buttons/Switcher.svelte\";\n    import SettingsButton from \"$components/buttons/SettingsButton.svelte\";\n    import SettingsToggle from \"$components/buttons/SettingsToggle.svelte\";\n\n    const codecTitles = {\n        h264: \"h264 + aac\",\n        av1: \"av1 + opus\",\n        vp9: \"vp9 + opus\",\n    }\n</script>\n\n<SettingsCategory\n    sectionId=\"quality\"\n    title={$t(\"settings.video.quality\")}\n>\n    <Switcher big={true} description={$t(\"settings.video.quality.description\")}>\n        {#each videoQualityOptions as value}\n            <SettingsButton\n                settingContext=\"save\"\n                settingId=\"videoQuality\"\n                settingValue={value}\n            >\n                {$t(`settings.video.quality.${value}`)}\n            </SettingsButton>\n        {/each}\n    </Switcher>\n</SettingsCategory>\n\n<SettingsCategory\n    sectionId=\"youtube-codec\"\n    title={$t(\"settings.video.youtube.codec\")}\n>\n    <Switcher\n        big={true}\n        description={$t(\"settings.video.youtube.codec.description\")}\n    >\n        {#each youtubeVideoCodecOptions as value}\n            <SettingsButton\n                settingContext=\"save\"\n                settingId=\"youtubeVideoCodec\"\n                settingValue={value}\n            >\n                {codecTitles[value]}\n            </SettingsButton>\n        {/each}\n    </Switcher>\n</SettingsCategory>\n\n<SettingsCategory\n    sectionId=\"youtube-container\"\n    title={$t(\"settings.video.youtube.container\")}\n>\n    <Switcher\n        big={true}\n        description={$t(\"settings.video.youtube.container.description\")}\n    >\n        {#each youtubeVideoContainerOptions as value}\n            <SettingsButton\n                settingContext=\"save\"\n                settingId=\"youtubeVideoContainer\"\n                settingValue={value}\n            >\n                {value}\n            </SettingsButton>\n        {/each}\n    </Switcher>\n</SettingsCategory>\n\n{#if env.ENABLE_DEPRECATED_YOUTUBE_HLS}\n    <SettingsCategory\n        sectionId=\"youtube-hls\"\n        title={$t(\"settings.video.youtube.hls\")}\n        disabled={$settings.save.youtubeVideoCodec === \"av1\"}\n        beta\n    >\n        <SettingsToggle\n            settingContext=\"save\"\n            settingId=\"youtubeHLS\"\n            title={$t(\"settings.video.youtube.hls.title\")}\n            description={$t(\"settings.video.youtube.hls.description\")}\n            disabled={$settings.save.youtubeVideoCodec === \"av1\"}\n        />\n    </SettingsCategory>\n{/if}\n\n<SettingsCategory sectionId=\"h265\" title={$t(\"settings.video.h265\")}>\n    <SettingsToggle\n        settingContext=\"save\"\n        settingId=\"allowH265\"\n        title={$t(\"settings.video.h265.title\")}\n        description={$t(\"settings.video.h265.description\")}\n    />\n</SettingsCategory>\n\n<SettingsCategory sectionId=\"convert-gif\" title={$t(\"settings.video.twitter.gif\")}>\n    <SettingsToggle\n        settingContext=\"save\"\n        settingId=\"convertGif\"\n        title={$t(\"settings.video.twitter.gif.title\")}\n        description={$t(\"settings.video.twitter.gif.description\")}\n    />\n</SettingsCategory>\n\n"
  },
  {
    "path": "web/src/routes/updates/+page.svelte",
    "content": "<script lang=\"ts\">\n    import { page } from \"$app/stores\";\n    import { browser } from \"$app/environment\";\n\n    import { t } from \"$lib/i18n/translations\";\n    import { getAllChangelogs } from \"$lib/changelogs\";\n    import type { Optional } from \"$lib/types/generic\";\n    import type { ChangelogImport } from \"$lib/types/changelogs\";\n\n    import ChangelogEntry from \"$components/changelog/ChangelogEntry.svelte\";\n\n    import IconArrowLeft from \"@tabler/icons-svelte/IconArrowLeft.svelte\";\n    import IconArrowRight from \"@tabler/icons-svelte/IconArrowRight.svelte\";\n\n    const changelogs = getAllChangelogs();\n    const versions = Object.keys(changelogs);\n\n    let changelog: Optional<{\n        version: string;\n        page: Promise<ChangelogImport>;\n    }>;\n    let currentIndex = 0;\n    let wrapper: HTMLDivElement;\n\n    {\n        const hash = $page.url.hash.replace(\"#\", \"\");\n        const versionIndex = versions.indexOf(hash);\n        if (versionIndex !== -1 && currentIndex !== versionIndex) {\n            currentIndex = versionIndex;\n        }\n    }\n\n    const loadChangelog = async () => {\n        const version = versions[currentIndex];\n        changelog = {\n            version,\n            page: changelogs[version]() as Promise<ChangelogImport>,\n        };\n\n        if (browser) {\n            window.location.hash = version;\n        }\n\n        await changelog.page;\n    };\n\n    const loadNext = () => {\n        if (currentIndex < versions.length - 1) ++currentIndex;\n    };\n\n    const loadPrev = () => {\n        if (currentIndex > 0) --currentIndex;\n    };\n\n    const preloadNext = () => {\n        if (!next) return;\n        changelogs[next]().catch(() => {});\n    };\n\n    const handleKeydown = (e: KeyboardEvent) => {\n        if (e.key === \"ArrowLeft\") loadPrev();\n        else if (e.key === \"ArrowRight\") loadNext();\n    };\n\n    const handleScroll = (e: WheelEvent) => {\n        if (!(e.target instanceof HTMLElement)) {\n            return;\n        }\n\n        if (!wrapper.contains(e.target)) {\n            wrapper.scrollTop += e.deltaY;\n            e.preventDefault();\n        }\n    };\n\n    $: prev = versions[currentIndex - 1];\n    $: next = versions[currentIndex + 1];\n    $: currentIndex, loadChangelog();\n</script>\n\n<svelte:head>\n    <title>\n        {$t(\"tabs.updates\")} ~ {$t(\"general.cobalt\")}\n    </title>\n    <meta\n        property=\"og:title\"\n        content=\"{$t(\"tabs.updates\")} ~ {$t(\"general.cobalt\")}\"\n    />\n</svelte:head>\n\n<svelte:window on:keydown={handleKeydown} />\n\n<div class=\"news\" tabindex=\"-1\" on:wheel={handleScroll}>\n    {#if changelog}\n        <div id=\"left-button\" class=\"button-wrapper-desktop\">\n            {#if prev}\n                <button\n                    on:click={loadPrev}\n                    aria-label={$t(\"updates.button.previous\", { value: prev })}\n                >\n                    <IconArrowLeft />\n                    {prev || \"\"}\n                </button>\n            {/if}\n        </div>\n        <div class=\"changelog-wrapper\" bind:this={wrapper}>\n            {#await changelog.page}\n                {#key changelog.version}\n                    <ChangelogEntry\n                        version={changelog.version}\n                        skeleton\n                    />\n                {/key}\n            {:then page}\n                <svelte:component\n                    this={page.default}\n                    {...page.metadata}\n                    version={changelog.version}\n                />\n            {/await}\n\n            <div class=\"button-wrapper-mobile\" class:only-right={!prev}>\n                {#if prev}\n                    <button\n                        on:click={loadPrev}\n                        aria-label={$t(\"updates.button.previous\", { value: prev })}\n                    >\n                        <IconArrowLeft />\n                        {prev || \"\"}\n                    </button>\n                {/if}\n                {#if next}\n                    <button\n                        on:click={loadNext}\n                        on:focus={preloadNext}\n                        on:mousemove={preloadNext}\n                        aria-label={$t(\"updates.button.next\", { value: next })}\n                    >\n                        {next || \"\"}\n                        <IconArrowRight />\n                    </button>\n                {/if}\n            </div>\n        </div>\n        <div id=\"right-button\" class=\"button-wrapper-desktop\">\n            {#if next}\n                <button\n                    on:click={loadNext}\n                    on:focus={preloadNext}\n                    on:mousemove={preloadNext}\n                    aria-label={$t(\"updates.button.next\", { value: next })}\n                >\n                    {next || \"\"}\n                    <IconArrowRight />\n                </button>\n            {/if}\n        </div>\n    {/if}\n</div>\n\n<style>\n    .news {\n        display: flex;\n        width: 100%;\n        flex-direction: row;\n        justify-content: space-evenly;\n    }\n\n    .button-wrapper-desktop {\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        height: 100%;\n    }\n\n    .button-wrapper-desktop button {\n        position: absolute;\n        background-color: transparent;\n        display: flex;\n        border: none;\n    }\n\n    button :global(svg) {\n        stroke-width: 1.6px;\n    }\n\n    .button-wrapper-desktop button {\n        box-shadow: none;\n    }\n\n    .changelog-wrapper {\n        flex: 1;\n        max-width: 850px;\n        overflow-x: hidden;\n        padding: var(--padding);\n        padding-top: calc(var(--padding) + 1em);\n    }\n\n    .button-wrapper-mobile {\n        display: none;\n    }\n\n    @media only screen and (max-width: 1150px) {\n        .button-wrapper-mobile {\n            display: flex;\n            padding: var(--border-radius);\n            padding-top: 0;\n            justify-content: space-between;\n        }\n\n        .button-wrapper-mobile.only-right {\n            justify-content: end;\n        }\n\n        .button-wrapper-desktop {\n            display: none;\n        }\n    }\n\n    @media screen and (max-width: 535px) {\n        .changelog-wrapper {\n            padding-top: var(--padding);\n        }\n    }\n</style>\n"
  },
  {
    "path": "web/src/routes/version.json/+server.ts",
    "content": "import { json } from \"@sveltejs/kit\";\nimport { getCommit, getBranch, getRemote, getVersion } from \"@imput/version-info\";\n\nexport async function GET() {\n    return json({\n        commit: await getCommit(),\n        branch: await getBranch(),\n        remote: await getRemote(),\n        version: await getVersion()\n    });\n}\n\nexport const prerender = true;\n"
  },
  {
    "path": "web/static/manifest.json",
    "content": "{\n    \"name\": \"cobalt\",\n    \"short_name\": \"cobalt\",\n    \"start_url\": \"/\",\n    \"icons\": [\n        {\n            \"src\": \"/icons/android-chrome-192x192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"/icons/android-chrome-512x512.png\",\n            \"sizes\": \"512x512\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"/icons/generic.png\",\n            \"sizes\": \"512x512\",\n            \"type\": \"image/png\",\n            \"purpose\": \"any\"\n        },\n        {\n            \"src\": \"/icons/maskable/48.png\",\n            \"sizes\": \"48x48\",\n            \"type\": \"image/png\",\n            \"purpose\": \"maskable\"\n        },\n        {\n            \"src\": \"/icons/maskable/72.png\",\n            \"sizes\": \"72x72\",\n            \"type\": \"image/png\",\n            \"purpose\": \"maskable\"\n        },\n        {\n            \"src\": \"/icons/maskable/96.png\",\n            \"sizes\": \"96x96\",\n            \"type\": \"image/png\",\n            \"purpose\": \"maskable\"\n        },\n        {\n            \"src\": \"/icons/maskable/128.png\",\n            \"sizes\": \"128x128\",\n            \"type\": \"image/png\",\n            \"purpose\": \"maskable\"\n        },\n        {\n            \"src\": \"/icons/maskable/192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\",\n            \"purpose\": \"maskable\"\n        },\n        {\n            \"src\": \"/icons/maskable/384.png\",\n            \"sizes\": \"384x384\",\n            \"type\": \"image/png\",\n            \"purpose\": \"maskable\"\n        },\n        {\n            \"src\": \"/icons/maskable/512.png\",\n            \"sizes\": \"512x512\",\n            \"type\": \"image/png\",\n            \"purpose\": \"maskable\"\n        }\n    ],\n    \"share_target\": {\n        \"method\": \"GET\",\n        \"action\": \"/\",\n        \"enctype\": \"application/x-www-form-urlencoded\",\n        \"params\": {\n            \"text\": \"u\",\n            \"url\": \"u\"\n        }\n    },\n    \"theme_color\": \"#000000\",\n    \"background_color\": \"#000000\",\n    \"display\": \"standalone\"\n}\n"
  },
  {
    "path": "web/svelte.config.js",
    "content": "import \"dotenv/config\";\nimport adapter from \"@sveltejs/adapter-static\";\n\nimport { mdsvex } from \"mdsvex\";\nimport { fileURLToPath } from \"node:url\";\nimport { dirname, join } from \"node:path\";\nimport { sveltePreprocess } from \"svelte-preprocess\";\n\n/** @type {import('@sveltejs/kit').Config} */\nconst config = {\n    // Consult https://kit.svelte.dev/docs/integrations#preprocessors\n    // for more information about preprocessors\n    extensions: [\".svelte\", \".md\"],\n    preprocess: [\n        {\n            name: \"strip-announcer\",\n            markup: ({ content: code }) => {\n                code = code.replace(\n                    /<div id=\"svelte-announcer\" [\\s\\S]*?<\\/div>/,\n                    '{null}'\n                );\n\n                return { code }\n            }\n        },\n        sveltePreprocess(),\n        mdsvex({\n            extensions: ['.md'],\n            layout: {\n                about: join(\n                    dirname(fileURLToPath(import.meta.url)),\n                    '/src/components/misc/AboutPageWrapper.svelte'\n                ),\n                changelogs: join(\n                    dirname(fileURLToPath(import.meta.url)),\n                    '/src/components/changelog/ChangelogEntryWrapper.svelte'\n                )\n            }\n        })\n    ],\n    kit: {\n        adapter: adapter({\n            // default options are shown. On some platforms\n            // these options are set automatically — see below\n            pages: 'build',\n            assets: 'build',\n            fallback: '404.html',\n            precompress: false,\n            strict: true\n        }),\n        csp: {\n            mode: \"hash\",\n            directives: {\n                \"connect-src\": [\"*\"],\n                \"default-src\": [\"none\"],\n\n                \"font-src\": [\"self\"],\n                \"style-src\": [\"self\", \"unsafe-inline\"],\n                \"img-src\": [\"*\", \"data:\"],\n                \"manifest-src\": [\"self\"],\n                \"worker-src\": [\"self\"],\n\n                \"object-src\": [\"none\"],\n                \"frame-src\": [\n                    \"self\",\n                    \"challenges.cloudflare.com\"\n                ],\n\n                \"script-src\": [\n                    \"self\",\n                    \"wasm-unsafe-eval\",\n                    \"challenges.cloudflare.com\",\n\n                    // eslint-disable-next-line no-undef\n                    process.env.WEB_PLAUSIBLE_HOST ? process.env.WEB_PLAUSIBLE_HOST : \"\",\n\n                    // hash of the theme preloader in app.html\n                    \"sha256-g67gIjM3G8yMbjbxyc3QUoVsKhdxgcQzCmSKXiZZo6s=\",\n                ],\n\n                \"script-src-attr\": [\n                    \"unsafe-hashes\",\n                    // hash of inline img event call\n                    // see: https://github.com/sveltejs/svelte/issues/14014\n                    \"sha256-7dQwUgLau1NFCCGjfn9FsYptB6ZtWxJin6VohGIu20I=\"\n                ],\n\n                \"frame-ancestors\": [\"none\"]\n            }\n        },\n        env: {\n            publicPrefix: 'WEB_'\n        },\n        version: {\n            pollInterval: 60000\n        },\n        paths: {\n            relative: false\n        },\n        alias: {\n            $components: 'src/components',\n            $i18n: 'i18n',\n        }\n    }\n};\n\nexport default config;\n"
  },
  {
    "path": "web/tsconfig.json",
    "content": "{\n    \"extends\": \"./.svelte-kit/tsconfig.json\",\n    \"compilerOptions\": {\n        \"allowJs\": true,\n        \"checkJs\": true,\n        \"esModuleInterop\": true,\n        \"forceConsistentCasingInFileNames\": true,\n        \"resolveJsonModule\": true,\n        \"skipLibCheck\": true,\n        \"sourceMap\": true,\n        \"strict\": true,\n        \"moduleResolution\": \"bundler\",\n        \"types\": [\"turnstile-types\"],\n        \"lib\": [\"WebWorker\"]\n    }\n    // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias\n    // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files\n    //\n    // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes\n    // from the referenced tsconfig.json - TypeScript does not merge them in\n}\n"
  },
  {
    "path": "web/vite.config.ts",
    "content": "import mime from \"mime\";\nimport basicSSL from \"@vitejs/plugin-basic-ssl\";\n\nimport { glob } from \"glob\";\nimport { sveltekit } from \"@sveltejs/kit/vite\";\nimport { createSitemap } from \"svelte-sitemap/src/index\";\nimport { defineConfig, searchForWorkspaceRoot, type PluginOption } from \"vite\";\n\nimport { join, basename } from \"node:path\";\nimport { createReadStream } from \"node:fs\";\nimport { cp, readdir, mkdir } from \"node:fs/promises\";\n\nconst exposeLibAV: PluginOption = (() => {\n    const IMPUT_MODULE_DIR = join(__dirname, 'node_modules/@imput');\n    return {\n        name: \"vite-libav.js\",\n        configureServer(server) {\n            server.middlewares.use(async (req, res, next) => {\n                if (!req.url?.startsWith('/_libav/')) return next();\n\n                const filename = basename(req.url).split('?')[0];\n                if (!filename) return next();\n\n                const [file] = await glob(join(IMPUT_MODULE_DIR, '/**/dist/', filename));\n                if (!file) return next();\n\n                const fileType = mime.getType(filename);\n                if (!fileType) return next();\n\n                res.setHeader('Content-Type', fileType);\n                return createReadStream(file).pipe(res);\n            });\n        },\n        generateBundle: async (options) => {\n            if (!options.dir) {\n                return;\n            }\n\n            const assets = join(options.dir, '_libav');\n            await mkdir(assets, { recursive: true });\n\n            const modules = await readdir(IMPUT_MODULE_DIR).then(\n                modules => modules.filter(m => m.startsWith('libav.js'))\n            );\n\n            for (const module of modules) {\n                const distFolder = join(IMPUT_MODULE_DIR, module, 'dist/');\n                await cp(distFolder, assets, { recursive: true });\n            }\n        }\n    }\n})();\n\nconst enableCOEP: PluginOption = {\n    name: \"isolation\",\n    configureServer(server) {\n        server.middlewares.use((_req, res, next) => {\n            res.setHeader(\"Cross-Origin-Opener-Policy\", \"same-origin\");\n            res.setHeader(\"Cross-Origin-Embedder-Policy\", \"require-corp\");\n            next();\n        })\n    }\n};\n\nconst generateSitemap: PluginOption = {\n    name: \"generate-sitemap\",\n    async writeBundle(bundle) {\n        if (!process.env.WEB_HOST || !bundle.dir?.endsWith('server')) {\n            return;\n        }\n\n        await createSitemap(`https://${process.env.WEB_HOST}`, {\n            changeFreq: 'monthly',\n            outDir: '.svelte-kit/output/prerendered/pages',\n            resetTime: true\n        });\n    }\n}\n\nconst checkDefaultApiEnv = (): PluginOption => ({\n    name: \"check-default-api\",\n    config() {\n        if (!process.env.WEB_DEFAULT_API) {\n            throw new Error(\n                \"WEB_DEFAULT_API env variable is required, but missing.\"\n            );\n        }\n    },\n});\n\nexport default defineConfig({\n    plugins: [\n        checkDefaultApiEnv(),\n        basicSSL(),\n        sveltekit(),\n        enableCOEP,\n        exposeLibAV,\n        generateSitemap\n    ],\n    build: {\n        sourcemap: true,\n        rollupOptions: {\n            output: {\n                manualChunks: (id) => {\n                    if (id.includes('/web/i18n') && id.endsWith('.json')) {\n                        const lang = id.split('/web/i18n/')?.[1].split('/')?.[0];\n                        if (lang) {\n                            return `i18n_${lang}`;\n                        }\n                    }\n                }\n            }\n        }\n    },\n    server: {\n        headers: {\n            \"Cross-Origin-Opener-Policy\": \"same-origin\",\n            \"Cross-Origin-Embedder-Policy\": \"require-corp\"\n        },\n        fs: {\n            allow: [\n                searchForWorkspaceRoot(process.cwd())\n            ]\n        },\n        proxy: {}\n    },\n    optimizeDeps: {\n        exclude: [\"@imput/libav.js-remux-cli\"]\n    },\n});\n"
  },
  {
    "path": "web/wrangler.jsonc",
    "content": "{\n    \"name\": \"cobalt\",\n    \"compatibility_date\": \"2025-06-01\",\n    \"assets\": {\n        \"directory\": \"./build\",\n        \"not_found_handling\": \"404-page\"\n    }\n}\n"
  }
]